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
+356
View File
@@ -0,0 +1,356 @@
<?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 mod_quiz\local;
use mod_quiz\form\preflight_check_form;
use mod_quiz_mod_form;
use moodle_page;
use MoodleQuickForm;
use mod_quiz\quiz_settings;
use stdClass;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
/**
* Base class for rules that restrict the ability to attempt a quiz.
*
* Quiz access rule plugins must sublclass this one to form their main 'rule' class.
* Most of the methods are defined in a slightly unnatural way because we either
* want to say that access is allowed, or explain the reason why it is block.
* Therefore instead of is_access_allowed(...) we have prevent_access(...) that
* return false if access is permitted, or a string explanation (which is treated
* as true) if access should be blocked. Slighly unnatural, but actually the easiest
* way to implement this.
*
* @package mod_quiz
* @copyright 2009 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 2.2
*/
abstract class access_rule_base {
/** @var stdClass the quiz settings. */
protected $quiz;
/** @var quiz_settings the quiz object. */
protected $quizobj;
/** @var int the time to use as 'now'. */
protected $timenow;
/**
* Create an instance of this rule for a particular quiz.
*
* @param quiz_settings $quizobj information about the quiz in question.
* @param int $timenow the time that should be considered as 'now'.
*/
public function __construct($quizobj, $timenow) {
$this->quizobj = $quizobj;
$this->quiz = $quizobj->get_quiz();
$this->timenow = $timenow;
}
/**
* Return an appropriately configured instance of this rule, if it is applicable
* to the given quiz, otherwise return null.
*
* @param quiz_settings $quizobj information about the quiz in question.
* @param int $timenow the time that should be considered as 'now'.
* @param bool $canignoretimelimits whether the current user is exempt from
* time limits by the mod/quiz:ignoretimelimits capability.
* @return self|null the rule, if applicable, else null.
*/
public static function make(quiz_settings $quizobj, $timenow, $canignoretimelimits) {
return null;
}
/**
* Whether a user should be allowed to start a new attempt at this quiz now.
*
* @param int $numprevattempts the number of previous attempts this user has made.
* @param stdClass $lastattempt information about the user's last completed attempt.
* @return string false if access should be allowed, a message explaining the
* reason if access should be prevented.
*/
public function prevent_new_attempt($numprevattempts, $lastattempt) {
return false;
}
/**
* Whether the user should be blocked from starting a new attempt or continuing
* an attempt now.
* @return string false if access should be allowed, a message explaining the
* reason if access should be prevented.
*/
public function prevent_access() {
return false;
}
/**
* Does this rule require a UI check with the user before an attempt is started?
*
* @param int|null $attemptid the id of the current attempt, if there is one,
* otherwise null.
* @return bool whether a check is required before the user starts/continues
* their attempt.
*/
public function is_preflight_check_required($attemptid) {
return false;
}
/**
* Add any field you want to pre-flight check form. You should only do
* something here if {@see is_preflight_check_required()} returned true.
*
* @param preflight_check_form $quizform the form being built.
* @param MoodleQuickForm $mform The wrapped MoodleQuickForm.
* @param int|null $attemptid the id of the current attempt, if there is one,
* otherwise null.
*/
public function add_preflight_check_form_fields(preflight_check_form $quizform,
MoodleQuickForm $mform, $attemptid) {
// Do nothing by default.
}
/**
* Validate the pre-flight check form submission. You should only do
* something here if {@see is_preflight_check_required()} returned true.
*
* If the form validates, the user will be allowed to continue.
*
* @param array $data the submitted form data.
* @param array $files any files in the submission.
* @param array $errors the list of validation errors that is being built up.
* @param int|null $attemptid the id of the current attempt, if there is one,
* otherwise null.
* @return array the update $errors array;
*/
public function validate_preflight_check($data, $files, $errors, $attemptid) {
return $errors;
}
/**
* The pre-flight check has passed. This is a chance to record that fact in
* some way.
* @param int|null $attemptid the id of the current attempt, if there is one,
* otherwise null.
*/
public function notify_preflight_check_passed($attemptid) {
// Do nothing by default.
}
/**
* This is called when the current attempt at the quiz is finished. This is
* used, for example by the password rule, to clear the flag in the session.
*/
public function current_attempt_finished() {
// Do nothing by default.
}
/**
* Return a brief summary of this rule, to show to users, if required.
*
* This information is show shown, for example, on the quiz view page, to explain this
* restriction. There is no obligation to return anything. If it is not appropriate to
* tell students about this rule, then just return ''.
*
* @return string a message, or array of messages, explaining the restriction
* (may be '' if no message is appropriate).
*/
public function description() {
return '';
}
/**
* Is the current user unable to start any more attempts in future, because of this rule?
*
* If this rule can determine that this user will never be allowed another attempt at
* this quiz, for example because the last possible start time is past, or all attempts
* have been used up, then return true. This is used to know whether to display a
* final grade on the view page. This will only be called if there is not a currently
* active attempt for this user.
*
* @param int $numprevattempts the number of previous attempts this user has made.
* @param stdClass $lastattempt information about the user's last completed attempt.
* @return bool true if this rule means that this user will never be allowed another
* attempt at this quiz.
*/
public function is_finished($numprevattempts, $lastattempt) {
return false;
}
/**
* Time by which, according to this rule, the user has to finish their attempt.
*
* @param stdClass $attempt the current attempt
* @return int|false the attempt close time, or false if there is no close time.
*/
public function end_time($attempt) {
return false;
}
/**
* If the user should be shown a different amount of time than $timenow - $this->end_time(), then
* override this method. This is useful if the time remaining is large enough to be omitted.
* @param stdClass $attempt the current attempt
* @param int $timenow the time now. We don't use $this->timenow, so we can
* give the user a more accurate indication of how much time is left.
* @return mixed the time left in seconds (can be negative) or false if there is no limit.
*/
public function time_left_display($attempt, $timenow) {
$endtime = $this->end_time($attempt);
if ($endtime === false) {
return false;
}
return $endtime - $timenow;
}
/**
* Does this rule requires the attempt (and review) to be displayed in a pop-up window?
*
* @return bool true if it does.
*/
public function attempt_must_be_in_popup() {
return false;
}
/**
* Any options required when showing the attempt in a pop-up.
*
* @return array any options that are required for showing the attempt page
* in a popup window.
*/
public function get_popup_options() {
return [];
}
/**
* Sets up the attempt (review or summary) page with any special extra
* properties required by this rule. securewindow rule is an example of where
* this is used.
*
* @param moodle_page $page the page object to initialise.
*/
public function setup_attempt_page($page) {
// Do nothing by default.
}
/**
* It is possible for one rule to override other rules.
*
* The aim is that third-party rules should be able to replace sandard rules
* if they want. See, for example MDL-13592.
*
* @return array plugin names of other rules that this one replaces.
* For example ['ipaddress', 'password'].
*/
public function get_superceded_rules() {
return [];
}
/**
* Add any fields that this rule requires to the quiz settings form. This
* method is called from {@see mod_quiz_mod_form::definition()}, while the
* security seciton is being built.
* @param mod_quiz_mod_form $quizform the quiz settings form that is being built.
* @param MoodleQuickForm $mform the wrapped MoodleQuickForm.
*/
public static function add_settings_form_fields(
mod_quiz_mod_form $quizform, MoodleQuickForm $mform) {
// By default do nothing.
}
/**
* Validate the data from any form fields added using {@see add_settings_form_fields()}.
* @param array $errors the errors found so far.
* @param array $data the submitted form data.
* @param array $files information about any uploaded files.
* @param mod_quiz_mod_form $quizform the quiz form object.
* @return array $errors the updated $errors array.
*/
public static function validate_settings_form_fields(array $errors,
array $data, $files, mod_quiz_mod_form $quizform) {
return $errors;
}
/**
* Get any options this rule adds to the 'Browser security' quiz setting.
*
* @return array key => lang string any choices to add to the quiz Browser
* security settings menu.
*/
public static function get_browser_security_choices() {
return [];
}
/**
* Save any submitted settings when the quiz settings form is submitted. This
* is called from {@see quiz_after_add_or_update()} in lib.php.
* @param stdClass $quiz the data from the quiz form, including $quiz->id
* which is the id of the quiz being saved.
*/
public static function save_settings($quiz) {
// By default do nothing.
}
/**
* Delete any rule-specific settings when the quiz is deleted. This is called
* from {@see quiz_delete_instance()} in lib.php.
* @param stdClass $quiz the data from the database, including $quiz->id
* which is the id of the quiz being deleted.
* @since Moodle 2.7.1, 2.6.4, 2.5.7
*/
public static function delete_settings($quiz) {
// By default do nothing.
}
/**
* Return the bits of SQL needed to load all the settings from all the access
* plugins in one DB query. The easiest way to understand what you need to do
* here is probably to read the code of {@see access_manager::load_settings()}.
*
* If you have some settings that cannot be loaded in this way, then you can
* use the {@see get_extra_settings()} method instead, but that has
* performance implications.
*
* @param int $quizid the id of the quiz we are loading settings for. This
* can also be accessed as quiz.id in the SQL. (quiz is a table alisas for {quiz}.)
* @return array with three elements:
* 1. fields: any fields to add to the select list. These should be alised
* if neccessary so that the field name starts the name of the plugin.
* 2. joins: any joins (should probably be LEFT JOINS) with other tables that
* are needed.
* 3. params: array of placeholder values that are needed by the SQL. You must
* used named placeholders, and the placeholder names should start with the
* plugin name, to avoid collisions.
*/
public static function get_settings_sql($quizid) {
return ['', '', []];
}
/**
* You can use this method to load any extra settings your plugin has that
* cannot be loaded efficiently with get_settings_sql().
* @param int $quizid the quiz id.
* @return array setting value name => value. The value names should all
* start with the name of your plugin to avoid collisions.
*/
public static function get_extra_settings($quizid) {
return [];
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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 mod_quiz\local;
/**
* Cache manager for quiz overrides
*
* Override cache data is set via its data source, {@see \mod_quiz\cache\overrides}
* @package mod_quiz
* @copyright 2024 Matthew Hilton <matthewhilton@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class override_cache {
/** @var string invalidation event used to purge data when reset_userdata is called, {@see \cache_helper::purge_by_event()} **/
public const INVALIDATION_USERDATARESET = 'userdatareset';
/**
* Create override_cache object and link to quiz
*
* @param int $quizid The quiz to link this cache to
*/
public function __construct(
/** @var int $quizid ID of quiz cache is being operated on **/
protected readonly int $quizid
) {
}
/**
* Returns the override cache
*
* @return \cache
*/
protected function get_cache(): \cache {
return \cache::make('mod_quiz', 'overrides');
}
/**
* Returns group cache key
*
* @param int $groupid
* @return string the group cache key
*/
protected function get_group_cache_key(int $groupid): string {
return "{$this->quizid}_g_{$groupid}";
}
/**
* Returns user cache key
*
* @param int $userid
* @return string the user cache key
*/
protected function get_user_cache_key(int $userid): string {
return "{$this->quizid}_u_{$userid}";
}
/**
* Returns the override value in the cache for the given group
*
* @param int $groupid group to get cached override data for
* @return ?\stdClass override value in the cache for the given group, or null if there is none.
*/
public function get_cached_group_override(int $groupid): ?\stdClass {
$raw = $this->get_cache()->get($this->get_group_cache_key($groupid));
return empty($raw) || !is_object($raw) ? null : (object) $raw;
}
/**
* Returns the override value in the cache for the given user
*
* @param int $userid user to get cached override data for
* @return ?\stdClass the override value in the cache for the given user, or null if there is none.
*/
public function get_cached_user_override(int $userid): ?\stdClass {
$raw = $this->get_cache()->get($this->get_user_cache_key($userid));
return empty($raw) || !is_object($raw) ? null : (object) $raw;
}
/**
* Deletes the cached override data for a given group
*
* @param int $groupid group to delete data for
*/
public function clear_for_group(int $groupid): void {
$this->get_cache()->delete($this->get_group_cache_key($groupid));
}
/**
* Deletes the cached override data for the given user
*
* @param int $userid user to delete data for
*/
public function clear_for_user(int $userid): void {
$this->get_cache()->delete($this->get_user_cache_key($userid));
}
/**
* Clears the cache for the given user and/or group.
*
* @param ?int $userid user to delete data for, or null.
* @param ?int $groupid group to delete data for, or null.
*/
public function clear_for(?int $userid = null, ?int $groupid = null): void {
if (!empty($userid)) {
$this->clear_for_user($userid);
}
if (!empty($groupid)) {
$this->clear_for_group($groupid);
}
}
}
+630
View File
@@ -0,0 +1,630 @@
<?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 mod_quiz\local;
use mod_quiz\event\group_override_created;
use mod_quiz\event\group_override_deleted;
use mod_quiz\event\group_override_updated;
use mod_quiz\event\user_override_created;
use mod_quiz\event\user_override_deleted;
use mod_quiz\event\user_override_updated;
use mod_quiz\quiz_settings;
/**
* Manager class for quiz overrides
*
* @package mod_quiz
* @copyright 2024 Matthew Hilton <matthewhilton@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class override_manager {
/** @var array quiz setting keys that can be overwritten **/
private const OVERRIDEABLE_QUIZ_SETTINGS = ['timeopen', 'timeclose', 'timelimit', 'attempts', 'password'];
/**
* Create override manager
*
* @param \stdClass $quiz The quiz to link the manager to.
* @param \context_module $context Context being operated in
*/
public function __construct(
/** @var \stdClass The quiz linked to this manager instance **/
protected readonly \stdClass $quiz,
/** @var \context_module The context being operated in **/
public readonly \context_module $context
) {
global $CFG;
// Required for quiz_* methods.
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
// Sanity check that the context matches the quiz.
if (empty($quiz->cmid) || $quiz->cmid != $context->instanceid) {
throw new \coding_exception("Given context does not match the quiz object");
}
}
/**
* Returns all overrides for the linked quiz.
*
* @return array of quiz_override records
*/
public function get_all_overrides(): array {
global $DB;
return $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id]);
}
/**
* Validates the data, usually from a moodleform or a webservice call.
* If it contains an 'id' property, additional validation is performed against the existing record.
*
* @param array $formdata data from moodleform or webservice call.
* @return array array where the keys are error elements, and the values are lists of errors for each element.
*/
public function validate_data(array $formdata): array {
global $DB;
// Because this can be called directly (e.g. via edit_override_form)
// and not just through save_override, we must ensure the data
// is parsed in the same way.
$formdata = $this->parse_formdata($formdata);
$formdata = (object) $formdata;
$errors = [];
// Ensure at least one of the overrideable settings is set.
$keysthatareset = array_map(function ($key) use ($formdata) {
return isset($formdata->$key) && !is_null($formdata->$key);
}, self::OVERRIDEABLE_QUIZ_SETTINGS);
if (!in_array(true, $keysthatareset)) {
$errors['general'][] = new \lang_string('nooverridedata', 'quiz');
}
// Ensure quiz is a valid quiz.
if (empty($formdata->quiz) || empty(get_coursemodule_from_instance('quiz', $formdata->quiz))) {
$errors['quiz'][] = new \lang_string('overrideinvalidquiz', 'quiz');
}
// Ensure either userid or groupid is set.
if (empty($formdata->userid) && empty($formdata->groupid)) {
$errors['general'][] = new \lang_string('overridemustsetuserorgroup', 'quiz');
}
// Ensure not both userid and groupid are set.
if (!empty($formdata->userid) && !empty($formdata->groupid)) {
$errors['general'][] = new \lang_string('overridecannotsetbothgroupanduser', 'quiz');
}
// If group is set, ensure it is a real group.
if (!empty($formdata->groupid) && empty(groups_get_group($formdata->groupid))) {
$errors['groupid'][] = new \lang_string('overrideinvalidgroup', 'quiz');
}
// If user is set, ensure it is a valid user.
if (!empty($formdata->userid) && !\core_user::is_real_user($formdata->userid, true)) {
$errors['userid'][] = new \lang_string('overrideinvaliduser', 'quiz');
}
// Ensure timeclose is later than timeopen, if both are set.
if (!empty($formdata->timeclose) && !empty($formdata->timeopen) && $formdata->timeclose <= $formdata->timeopen) {
$errors['timeclose'][] = new \lang_string('closebeforeopen', 'quiz');
}
// Ensure attempts is a integer greater than or equal to 0 (0 is unlimited attempts).
if (isset($formdata->attempts) && ((int) $formdata->attempts < 0)) {
$errors['attempts'][] = new \lang_string('overrideinvalidattempts', 'quiz');
}
// Ensure timelimit is greather than zero.
if (!empty($formdata->timelimit) && $formdata->timelimit <= 0) {
$errors['timelimit'][] = new \lang_string('overrideinvalidtimelimit', 'quiz');
}
// Ensure other records do not exist with the same group or user.
if (!empty($formdata->quiz) && (!empty($formdata->userid) || !empty($formdata->groupid))) {
$existingrecordparams = ['quiz' => $formdata->quiz, 'groupid' => $formdata->groupid ?? null,
'userid' => $formdata->userid ?? null, ];
$records = $DB->get_records('quiz_overrides', $existingrecordparams, '', 'id');
// Ignore self if updating.
if (!empty($formdata->id)) {
unset($records[$formdata->id]);
}
// If count is not zero, it means existing records exist already for this user/group.
if (!empty($records)) {
$errors['general'][] = new \lang_string('overridemultiplerecordsexist', 'quiz');
}
}
// If is existing record, validate it against the existing record.
if (!empty($formdata->id)) {
$existingrecorderrors = self::validate_against_existing_record($formdata->id, $formdata);
$errors = array_merge($errors, $existingrecorderrors);
}
// Implode each value (array of error strings) into a single error string.
foreach ($errors as $key => $value) {
$errors[$key] = implode(",", $value);
}
return $errors;
}
/**
* Returns the existing quiz override record with the given ID or null if does not exist.
*
* @param int $id existing quiz override id
* @return ?\stdClass record, if exists
*/
private static function get_existing(int $id): ?\stdClass {
global $DB;
return $DB->get_record('quiz_overrides', ['id' => $id]) ?: null;
}
/**
* Validates the formdata against an existing record.
*
* @param int $existingid id of existing quiz override record
* @param \stdClass $formdata formdata, usually from moodleform or webservice call.
* @return array array where the keys are error elements, and the values are lists of errors for each element.
*/
private static function validate_against_existing_record(int $existingid, \stdClass $formdata): array {
$existingrecord = self::get_existing($existingid);
$errors = [];
// Existing record must exist.
if (empty($existingrecord)) {
$errors['general'][] = new \lang_string('overrideinvalidexistingid', 'quiz');
}
// Group value must match existing record if it is set in the formdata.
if (!empty($existingrecord) && !empty($formdata->groupid) && $existingrecord->groupid != $formdata->groupid) {
$errors['groupid'][] = new \lang_string('overridecannotchange', 'quiz');
}
// User value must match existing record if it is set in the formdata.
if (!empty($existingrecord) && !empty($formdata->userid) && $existingrecord->userid != $formdata->userid) {
$errors['userid'][] = new \lang_string('overridecannotchange', 'quiz');
}
return $errors;
}
/**
* Parses the formdata by finding only the OVERRIDEABLE_QUIZ_SETTINGS,
* clearing any values that match the existing quiz, and re-adds the user or group id.
*
* @param array $formdata data usually from moodleform or webservice call.
* @return array array containing parsed formdata, with keys as the properties and values as the values.
* Any values set the same as the existing quiz are set to null.
*/
public function parse_formdata(array $formdata): array {
// Get the data from the form that we want to update.
$settings = array_intersect_key($formdata, array_flip(self::OVERRIDEABLE_QUIZ_SETTINGS));
// Remove values that are the same as currently in the quiz.
$settings = $this->clear_unused_values($settings);
// Add the user / group back as applicable.
$userorgroupdata = array_intersect_key($formdata, array_flip(['userid', 'groupid', 'quiz', 'id']));
return array_merge($settings, $userorgroupdata);
}
/**
* Saves the given override. If an id is given, it updates, otherwise it creates a new one.
* Note, capabilities are not checked, {@see require_manage_capability()}
*
* @param array $formdata data usually from moodleform or webservice call.
* @return int updated/inserted record id
*/
public function save_override(array $formdata): int {
global $DB;
// Extract only the necessary data.
$datatoset = $this->parse_formdata($formdata);
$datatoset['quiz'] = $this->quiz->id;
// Validate the data is OK.
$errors = $this->validate_data($datatoset);
if (!empty($errors)) {
$errorstr = implode(',', $errors);
throw new \invalid_parameter_exception($errorstr);
}
// Insert or update.
$id = $datatoset['id'] ?? 0;
if (!empty($id)) {
$DB->update_record('quiz_overrides', $datatoset);
} else {
$id = $DB->insert_record('quiz_overrides', $datatoset);
}
$userid = $datatoset['userid'] ?? null;
$groupid = $datatoset['groupid'] ?? null;
// Clear the cache.
$cache = new override_cache($this->quiz->id);
$cache->clear_for($userid, $groupid);
// Trigger moodle events.
if (empty($formdata['id'])) {
$this->fire_created_event($id, $userid, $groupid);
} else {
$this->fire_updated_event($id, $userid, $groupid);
}
// Update open events.
quiz_update_open_attempts(['quizid' => $this->quiz->id]);
// Update calendar events.
$isgroup = !empty($datatoset['groupid']);
if ($isgroup) {
// If is group, must update the entire quiz calendar events.
quiz_update_events($this->quiz);
} else {
// If is just a user, can update only their calendar event.
quiz_update_events($this->quiz, (object) $datatoset);
}
return $id;
}
/**
* Deletes all the overrides for the linked quiz
*
* @param bool $shouldlog If true, will log a override_deleted event
*/
public function delete_all_overrides(bool $shouldlog = true): void {
global $DB;
$overrides = $DB->get_records('quiz_overrides', ['quiz' => $this->quiz->id], '', 'id,userid,groupid');
$this->delete_overrides($overrides, $shouldlog);
}
/**
* Deletes overrides given just their ID.
* Note, the given IDs must exist and user must have access to them otherwise an exception will be thrown.
* Also note, capabilities are not checked, {@see require_manage_capability()}
*
* @param array $ids IDs of overrides to delete
* @param bool $shouldlog If true, will log a override_deleted event
*/
public function delete_overrides_by_id(array $ids, bool $shouldlog = true): void {
global $DB;
$quizsettings = quiz_settings::create($this->quiz->id);
// Filter for those overrides user can access.
[$sql, $params] = self::get_override_in_sql($this->quiz->id, $ids);
$records = array_filter(
$DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,userid,groupid'),
fn(\stdClass $override) => $this->can_view_override(
$override,
$quizsettings->get_course(),
$quizsettings->get_cm(),
),
);
// Ensure all the given ids exist, so the user is aware if they give a dodgy id.
$missingids = array_diff($ids, array_keys($records));
if (!empty($missingids)) {
throw new \invalid_parameter_exception(get_string('overridemissingdelete', 'quiz', implode(',', $missingids)));
}
$this->delete_overrides($records, $shouldlog);
}
/**
* Builds sql and parameters to find overrides in quiz with the given ids
*
* @param int $quizid id of quiz
* @param array $ids array of quiz override ids
* @return array sql and params
*/
private static function get_override_in_sql(int $quizid, array $ids): array {
global $DB;
[$insql, $inparams] = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
$params = array_merge($inparams, ['quizid' => $quizid]);
$sql = 'id ' . $insql . ' AND quiz = :quizid';
return [$sql, $params];
}
/**
* Deletes the given overrides in the quiz linked to the override manager.
* Note - capabilities are not checked, {@see require_manage_capability()}
*
* @param array $overrides override to delete. Must specify an id, quizid, and either a userid or groupid.
* @param bool $shouldlog If true, will log a override_deleted event
*/
public function delete_overrides(array $overrides, bool $shouldlog = true): void {
global $DB;
foreach ($overrides as $override) {
if (empty($override->id)) {
throw new \coding_exception("All overrides must specify an ID");
}
// Sanity check that user xor group is specified.
// User or group is required to clear the cache.
self::ensure_userid_xor_groupid_set($override->userid ?? null, $override->groupid ?? null);
}
if (empty($overrides)) {
// Exit early, since delete select requires at least 1 record.
return;
}
// Match id and quiz.
[$sql, $params] = self::get_override_in_sql($this->quiz->id, array_column($overrides, 'id'));
$DB->delete_records_select('quiz_overrides', $sql, $params);
$cache = new override_cache($this->quiz->id);
// Perform other cleanup.
foreach ($overrides as $override) {
$userid = $override->userid ?? null;
$groupid = $override->groupid ?? null;
$cache->clear_for($userid, $groupid);
$this->delete_override_events($userid, $groupid);
if ($shouldlog) {
$this->fire_deleted_event($override->id, $userid, $groupid);
}
}
}
/**
* Ensures either userid or groupid is set, but not both.
* If neither or both are set, a coding exception is thrown.
*
* @param ?int $userid user for the record, or null
* @param ?int $groupid group for the record, or null
*/
private static function ensure_userid_xor_groupid_set(?int $userid = null, ?int $groupid = null): void {
$groupset = !empty($groupid);
$userset = !empty($userid);
// If either set, but not both (xor).
$xorset = $groupset ^ $userset;
if (!$xorset) {
throw new \coding_exception("Either userid or groupid must be specified, but not both.");
}
}
/**
* Deletes the events associated with the override.
*
* @param ?int $userid or null if groupid is specified
* @param ?int $groupid or null if the userid is specified
*/
private function delete_override_events(?int $userid = null, ?int $groupid = null): void {
global $DB;
// Sanity check.
self::ensure_userid_xor_groupid_set($userid, $groupid);
$eventssearchparams = ['modulename' => 'quiz', 'instance' => $this->quiz->id];
if (!empty($userid)) {
$eventssearchparams['userid'] = $userid;
}
if (!empty($groupid)) {
$eventssearchparams['groupid'] = $groupid;
}
$events = $DB->get_records('event', $eventssearchparams);
foreach ($events as $event) {
$eventold = \calendar_event::load($event);
$eventold->delete();
}
}
/**
* Requires the user has the override management capability
*/
public function require_manage_capability(): void {
require_capability('mod/quiz:manageoverrides', $this->context);
}
/**
* Requires the user has the override viewing capability
*/
public function require_read_capability(): void {
// If user can manage, they can also view.
// It would not make sense to be able to create and edit overrides without being able to view them.
if (!has_any_capability(['mod/quiz:viewoverrides', 'mod/quiz:manageoverrides'], $this->context)) {
throw new \required_capability_exception($this->context, 'mod/quiz:viewoverrides', 'nopermissions', '');
}
}
/**
* Determine whether user can view a given override record
*
* @param \stdClass $override
* @param \stdClass $course
* @param \cm_info $cm
* @return bool
*/
public function can_view_override(\stdClass $override, \stdClass $course, \cm_info $cm): bool {
if ($override->groupid) {
return groups_group_visible($override->groupid, $course, $cm);
} else {
return groups_user_groups_visible($course, $override->userid, $cm);
}
}
/**
* Builds common event data
*
* @param int $id override id
* @return array of data to add as parameters to an event.
*/
private function get_base_event_params(int $id): array {
return [
'context' => $this->context,
'other' => [
'quizid' => $this->quiz->id,
],
'objectid' => $id,
];
}
/**
* Log that a given override was deleted
*
* @param int $id of quiz override that was just deleted
* @param ?int $userid user attached to override record, or null
* @param ?int $groupid group attached to override record, or null
*/
private function fire_deleted_event(int $id, ?int $userid = null, ?int $groupid = null): void {
// Sanity check.
self::ensure_userid_xor_groupid_set($userid, $groupid);
$params = $this->get_base_event_params($id);
$params['objectid'] = $id;
if (!empty($userid)) {
$params['relateduserid'] = $userid;
user_override_deleted::create($params)->trigger();
}
if (!empty($groupid)) {
$params['other']['groupid'] = $groupid;
group_override_deleted::create($params)->trigger();
}
}
/**
* Log that a given override was created
*
* @param int $id of quiz override that was just created
* @param ?int $userid user attached to override record, or null
* @param ?int $groupid group attached to override record, or null
*/
private function fire_created_event(int $id, ?int $userid = null, ?int $groupid = null): void {
// Sanity check.
self::ensure_userid_xor_groupid_set($userid, $groupid);
$params = $this->get_base_event_params($id);
if (!empty($userid)) {
$params['relateduserid'] = $userid;
user_override_created::create($params)->trigger();
}
if (!empty($groupid)) {
$params['other']['groupid'] = $groupid;
group_override_created::create($params)->trigger();
}
}
/**
* Log that a given override was updated
*
* @param int $id of quiz override that was just updated
* @param ?int $userid user attached to override record, or null
* @param ?int $groupid group attached to override record, or null
*/
private function fire_updated_event(int $id, ?int $userid = null, ?int $groupid = null): void {
// Sanity check.
self::ensure_userid_xor_groupid_set($userid, $groupid);
$params = $this->get_base_event_params($id);
if (!empty($userid)) {
$params['relateduserid'] = $userid;
user_override_updated::create($params)->trigger();
}
if (!empty($groupid)) {
$params['other']['groupid'] = $groupid;
group_override_updated::create($params)->trigger();
}
}
/**
* Clears any overrideable settings in the formdata, where the value matches what is already in the quiz
* If they match, the data is set to null.
*
* @param array $formdata data usually from moodleform or webservice call.
* @return array formdata with same values cleared
*/
private function clear_unused_values(array $formdata): array {
foreach (self::OVERRIDEABLE_QUIZ_SETTINGS as $key) {
// If the formdata is the same as the current quiz object data, clear it.
if (isset($formdata[$key]) && $formdata[$key] == $this->quiz->$key) {
$formdata[$key] = null;
}
// Ensure these keys always are set (even if null).
$formdata[$key] = $formdata[$key] ?? null;
// If the formdata is empty, set it to null.
// This avoids putting 0, false, or '' into the DB since the override logic expects null.
// Attempts is the exception, it can have a integer value of '0', so we use is_numeric instead.
if ($key != 'attempts' && empty($formdata[$key])) {
$formdata[$key] = null;
}
if ($key == 'attempts' && !is_numeric($formdata[$key])) {
$formdata[$key] = null;
}
}
return $formdata;
}
/**
* Deletes orphaned group overrides in a given course.
* Note - permissions are not checked and events are not logged for performance reasons.
*
* @param int $courseid ID of course to delete orphaned group overrides in
* @return array array of quizzes that had orphaned group overrides.
*/
public static function delete_orphaned_group_overrides_in_course(int $courseid): array {
global $DB;
// It would be nice if we got the groupid that was deleted.
// Instead, we just update all quizzes with orphaned group overrides.
$sql = "SELECT o.id, o.quiz, o.groupid
FROM {quiz_overrides} o
JOIN {quiz} quiz ON quiz.id = o.quiz
LEFT JOIN {groups} grp ON grp.id = o.groupid
WHERE quiz.course = :courseid
AND o.groupid IS NOT NULL
AND grp.id IS NULL";
$params = ['courseid' => $courseid];
$records = $DB->get_records_sql($sql, $params);
$DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
// Purge cache for each record.
foreach ($records as $record) {
$cache = new override_cache($record->quiz);
$cache->clear_for_group($record->groupid);
}
return array_unique(array_column($records, 'quiz'));
}
}
@@ -0,0 +1,408 @@
<?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 mod_quiz\local\reports;
use coding_exception;
use context_module;
use mod_quiz\quiz_settings;
use moodle_url;
use stdClass;
use table_sql;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/tablelib.php');
/**
* Base class for quiz reports that are basically a table with one row for each attempt.
*
* @package mod_quiz
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class attempts_report extends report_base {
/** @var int default page size for reports. */
const DEFAULT_PAGE_SIZE = 30;
/** @var string constant used for the options, means all users with attempts. */
const ALL_WITH = 'all_with';
/** @var string constant used for the options, means only enrolled users with attempts. */
const ENROLLED_WITH = 'enrolled_with';
/** @var string constant used for the options, means only enrolled users without attempts. */
const ENROLLED_WITHOUT = 'enrolled_without';
/** @var string constant used for the options, means all enrolled users. */
const ENROLLED_ALL = 'enrolled_any';
/** @var string the mode this report is. */
protected $mode;
/** @var context_module the quiz context. */
protected $context;
/** @var attempts_report_options_form The settings form to use. */
protected $form;
/** @var string SQL fragment for selecting the attempt that gave the final grade,
* if applicable. */
protected $qmsubselect;
/** @var boolean caches the results of {@see should_show_grades()}. */
protected $showgrades = null;
/** @var quiz_settings|null quiz settings object. Set in {@see init()}. */
protected $quizobj = null;
/**
* Can be used in subclasses to cache this information, but it will only get set if you set it.
* @example an example use in quiz_overview_report.
*
* @var bool
*/
protected $hasgroupstudents;
/**
* Initialise various aspects of this report.
*
* @param string $mode
* @param string $formclass
* @param stdClass $quiz
* @param stdClass $cm
* @param stdClass $course
* @return array with four elements:
* 0 => integer the current group id (0 for none).
* 1 => \core\dml\sql_join Contains joins, wheres, params for all the students in this course.
* 2 => \core\dml\sql_join Contains joins, wheres, params for all the students in the current group.
* 3 => \core\dml\sql_join Contains joins, wheres, params for all the students to show in the report.
* Will be the same as either element 1 or 2.
*/
public function init($mode, $formclass, $quiz, $cm, $course): array {
$this->mode = $mode;
$this->quizobj = new quiz_settings($quiz, $cm, $course);
$this->context = $this->quizobj->get_context();
[$currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins] = $this->get_students_joins(
$cm, $course);
$this->qmsubselect = quiz_report_qm_filter_select($quiz);
$this->form = new $formclass($this->get_base_url(),
['quiz' => $quiz, 'currentgroup' => $currentgroup, 'context' => $this->context]);
return [$currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins];
}
/**
* Get the base URL for this report.
* @return moodle_url the URL.
*/
protected function get_base_url() {
return new moodle_url('/mod/quiz/report.php',
['id' => $this->context->instanceid, 'mode' => $this->mode]);
}
/**
* Get sql fragments (joins) which can be used to build queries that
* will select an appropriate set of students to show in the reports.
*
* @param stdClass $cm the course module.
* @param stdClass $course the course settings.
* @return array with four elements:
* 0 => integer the current group id (0 for none).
* 1 => \core\dml\sql_join Contains joins, wheres, params for all the students in this course.
* 2 => \core\dml\sql_join Contains joins, wheres, params for all the students in the current group.
* 3 => \core\dml\sql_join Contains joins, wheres, params for all the students to show in the report.
* Will be the same as either element 1 or 2.
*/
protected function get_students_joins($cm, $course = null) {
$currentgroup = $this->get_current_group($cm, $course, $this->context);
$empty = new \core\dml\sql_join();
if ($currentgroup == self::NO_GROUPS_ALLOWED) {
return [$currentgroup, $empty, $empty, $empty];
}
$studentsjoins = get_enrolled_with_capabilities_join($this->context, '',
['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']);
if (empty($currentgroup)) {
return [$currentgroup, $studentsjoins, $empty, $studentsjoins];
}
// We have a currently selected group.
$groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '',
['mod/quiz:attempt', 'mod/quiz:reviewmyattempts'], $currentgroup);
return [$currentgroup, $studentsjoins, $groupstudentsjoins, $groupstudentsjoins];
}
/**
* Outputs the things you commonly want at the top of a quiz report.
*
* Calls through to {@see print_header_and_tabs()} and then
* outputs the standard group selector, number of attempts summary,
* and messages to cover common cases when the report can't be shown.
*
* @param stdClass $cm the course_module information.
* @param stdClass $course the course settings.
* @param stdClass $quiz the quiz settings.
* @param attempts_report_options $options the current report settings.
* @param int $currentgroup the current group.
* @param bool $hasquestions whether there are any questions in the quiz.
* @param bool $hasstudents whether there are any relevant students.
*/
protected function print_standard_header_and_messages($cm, $course, $quiz,
$options, $currentgroup, $hasquestions, $hasstudents) {
global $OUTPUT;
$this->print_header_and_tabs($cm, $course, $quiz, $this->mode);
if (groups_get_activity_groupmode($cm)) {
// Groups are being used, so output the group selector if we are not downloading.
groups_print_activity_menu($cm, $options->get_url());
}
// Print information on the number of existing attempts.
if ($strattemptnum = quiz_num_attempt_summary($quiz, $cm, true, $currentgroup)) {
echo '<div class="quizattemptcounts">' . $strattemptnum . '</div>';
}
if (!$hasquestions) {
echo quiz_no_questions_message($quiz, $cm, $this->context);
} else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
echo $OUTPUT->notification(get_string('notingroup'));
} else if (!$hasstudents) {
echo $OUTPUT->notification(get_string('nostudentsyet'));
} else if ($currentgroup && !$this->hasgroupstudents) {
echo $OUTPUT->notification(get_string('nostudentsingroup'));
}
}
/**
* Add all the user-related columns to the $columns and $headers arrays.
* @param table_sql $table the table being constructed.
* @param array $columns the list of columns. Added to.
* @param array $headers the columns headings. Added to.
*/
protected function add_user_columns($table, &$columns, &$headers) {
global $CFG;
if (!$table->is_downloading() && $CFG->grade_report_showuserimage) {
$columns[] = 'picture';
$headers[] = '';
}
if (!$table->is_downloading()) {
$columns[] = 'fullname';
$headers[] = get_string('name');
} else {
$columns[] = 'lastname';
$headers[] = get_string('lastname');
$columns[] = 'firstname';
$headers[] = get_string('firstname');
}
$extrafields = \core_user\fields::get_identity_fields($this->context);
foreach ($extrafields as $field) {
$columns[] = $field;
$headers[] = \core_user\fields::get_display_name($field);
}
}
/**
* Set the display options for the user-related columns in the table.
* @param table_sql $table the table being constructed.
*/
protected function configure_user_columns($table) {
$table->column_suppress('picture');
$table->column_suppress('fullname');
$extrafields = \core_user\fields::get_identity_fields($this->context);
foreach ($extrafields as $field) {
$table->column_suppress($field);
}
$table->column_class('picture', 'picture');
$table->column_class('lastname', 'bold');
$table->column_class('firstname', 'bold');
$table->column_class('fullname', 'bold');
$table->column_sticky('fullname');
}
/**
* Add the state column to the $columns and $headers arrays.
* @param array $columns the list of columns. Added to.
* @param array $headers the columns headings. Added to.
*/
protected function add_state_column(&$columns, &$headers) {
global $PAGE;
$columns[] = 'state';
$headers[] = get_string('attemptstate', 'quiz');
$PAGE->requires->js_call_amd('mod_quiz/reopen_attempt_ui', 'init');
}
/**
* Add all the time-related columns to the $columns and $headers arrays.
* @param array $columns the list of columns. Added to.
* @param array $headers the columns headings. Added to.
*/
protected function add_time_columns(&$columns, &$headers) {
$columns[] = 'timestart';
$headers[] = get_string('startedon', 'quiz');
$columns[] = 'timefinish';
$headers[] = get_string('timecompleted', 'quiz');
$columns[] = 'duration';
$headers[] = get_string('attemptduration', 'quiz');
}
/**
* Add a column for the overall quiz grade and the overall feedback, if applicable.
*
* @param stdClass $quiz the quiz settings.
* @param bool $usercanseegrades whether the user is allowed to see grades for this quiz.
* @param array $columns the list of columns. Added to.
* @param array $headers the columns headings. Added to.
* @param bool $includefeedback whether to include the feedbacktext columns
*/
protected function add_grade_columns($quiz, $usercanseegrades, &$columns, &$headers, $includefeedback = true) {
if ($usercanseegrades) {
$columns[] = 'sumgrades';
$headers[] = get_string('gradenoun') . '/' .
quiz_format_grade($quiz, $quiz->grade);
}
if ($includefeedback && quiz_has_feedback($quiz)) {
$columns[] = 'feedbacktext';
$headers[] = get_string('feedback', 'quiz');
}
}
/**
* Add columns for any extra quiz grade items, if applicable.
*
* @param bool $usercanseegrades whether the user is allowed to see grades for this quiz.
* @param array $columns the list of columns. Added to.
* @param array $headers the columns headings. Added to.
*/
protected function add_grade_item_columns(bool $usercanseegrades, array &$columns, array &$headers) {
if (!$usercanseegrades) {
return;
}
$gradeitems = $this->quizobj->get_grade_calculator()->get_grade_items();
if (!$gradeitems) {
return;
}
foreach ($gradeitems as $gradeitem) {
$columns[] = 'marks' . $gradeitem->id;
$headers[] = format_string($gradeitem->name) . '/' .
quiz_format_grade($this->quizobj->get_quiz(), $gradeitem->maxmark);
}
}
/**
* Set up the table.
* @param attempts_report_table $table the table being constructed.
* @param array $columns the list of columns.
* @param array $headers the columns headings.
* @param moodle_url $reporturl the URL of this report.
* @param attempts_report_options $options the display options.
* @param bool $collapsible whether to allow columns in the report to be collapsed.
*/
protected function set_up_table_columns($table, $columns, $headers, $reporturl,
attempts_report_options $options, $collapsible) {
$table->set_quiz_setting($this->quizobj);
$table->define_columns($columns);
$table->define_headers($headers);
$table->sortable(true, 'uniqueid');
$table->define_baseurl($options->get_url());
$this->configure_user_columns($table);
$table->no_sorting('feedbacktext');
$table->column_class('sumgrades', 'bold');
$table->set_attribute('id', 'attempts');
$table->collapsible($collapsible);
}
/**
* Process any submitted actions.
* @param stdClass $quiz the quiz settings.
* @param stdClass $cm the cm object for the quiz.
* @param int $currentgroup the currently selected group.
* @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) the students in the current group.
* @param \core\dml\sql_join $allowedjoins (joins, wheres, params) the users whose attempt this user is allowed to modify.
* @param moodle_url $redirecturl where to redircet to after a successful action.
*/
protected function process_actions($quiz, $cm, $currentgroup, \core\dml\sql_join $groupstudentsjoins,
\core\dml\sql_join $allowedjoins, $redirecturl) {
if (empty($currentgroup) || $this->hasgroupstudents) {
if (optional_param('delete', 0, PARAM_BOOL) && confirm_sesskey()) {
if ($attemptids = optional_param_array('attemptid', [], PARAM_INT)) {
require_capability('mod/quiz:deleteattempts', $this->context);
$this->delete_selected_attempts($quiz, $cm, $attemptids, $allowedjoins);
redirect($redirecturl);
}
}
}
}
/**
* Delete the quiz attempts
* @param stdClass $quiz the quiz settings. Attempts that don't belong to
* this quiz are not deleted.
* @param stdClass $cm the course_module object.
* @param array $attemptids the list of attempt ids to delete.
* @param \core\dml\sql_join $allowedjoins (joins, wheres, params) This list of userids that are visible in the report.
* Users can only delete attempts that they are allowed to see in the report.
* Empty means all users.
*/
protected function delete_selected_attempts($quiz, $cm, $attemptids, \core\dml\sql_join $allowedjoins) {
global $DB;
foreach ($attemptids as $attemptid) {
if (empty($allowedjoins->joins)) {
$sql = "SELECT quiza.*
FROM {quiz_attempts} quiza
JOIN {user} u ON u.id = quiza.userid
WHERE quiza.id = :attemptid";
} else {
$sql = "SELECT quiza.*
FROM {quiz_attempts} quiza
JOIN {user} u ON u.id = quiza.userid
{$allowedjoins->joins}
WHERE {$allowedjoins->wheres} AND quiza.id = :attemptid";
}
$params = $allowedjoins->params + ['attemptid' => $attemptid];
$attempt = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE);
if (!$attempt || $attempt->quiz != $quiz->id || $attempt->preview != 0) {
// Ensure the attempt exists, belongs to this quiz and belongs to
// a student included in the report. If not skip.
continue;
}
// Set the course module id before calling quiz_delete_attempt().
$quiz->cmid = $cm->id;
quiz_delete_attempt($attempt, $quiz);
}
}
}
@@ -0,0 +1,286 @@
<?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 mod_quiz\local\reports;
use context_module;
use mod_quiz\quiz_attempt;
use moodle_url;
use stdClass;
/**
* Base class for the options that control what is visible in an {@see attempts_report}.
*
* @package mod_quiz
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class attempts_report_options {
/** @var string the report mode. */
public $mode;
/** @var stdClass the settings for the quiz being reported on. */
public $quiz;
/** @var stdClass the course module objects for the quiz being reported on. */
public $cm;
/** @var stdClass the course settings for the course the quiz is in. */
public $course;
/**
* @var array form field name => corresponding quiz_attempt:: state constant.
*/
protected static $statefields = [
'stateinprogress' => quiz_attempt::IN_PROGRESS,
'stateoverdue' => quiz_attempt::OVERDUE,
'statefinished' => quiz_attempt::FINISHED,
'stateabandoned' => quiz_attempt::ABANDONED,
];
/**
* @var string attempts_report::ALL_WITH, attempts_report::ENROLLED_WITH,
* attempts_report::ENROLLED_WITHOUT or attempts_report::ENROLLED_ALL
*/
public $attempts = attempts_report::ENROLLED_WITH;
/** @var int the currently selected group. 0 if no group is selected. */
public $group = 0;
/**
* @var array|null of quiz_attempt::IN_PROGRESS, etc. constants. null means
* no restriction.
*/
public $states = [quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE,
quiz_attempt::FINISHED, quiz_attempt::ABANDONED];
/**
* @var bool whether to show all finished attmepts, or just the one that gave
* the final grade for the user.
*/
public $onlygraded = false;
/** @var int Number of attempts to show per page. */
public $pagesize = attempts_report::DEFAULT_PAGE_SIZE;
/** @var string whether the data should be downloaded in some format, or '' to display it. */
public $download = '';
/** @var bool whether the current user has permission to see grades. */
public $usercanseegrades;
/** @var bool whether the report table should have a column of checkboxes. */
public $checkboxcolumn = false;
/**
* Constructor.
*
* @param string $mode which report these options are for.
* @param stdClass $quiz the settings for the quiz being reported on.
* @param stdClass $cm the course module objects for the quiz being reported on.
* @param stdClass $course the course settings for the coures this quiz is in.
*/
public function __construct($mode, $quiz, $cm, $course) {
$this->mode = $mode;
$this->quiz = $quiz;
$this->cm = $cm;
$this->course = $course;
$this->usercanseegrades = quiz_report_should_show_grades($quiz, context_module::instance($cm->id));
}
/**
* Get the URL parameters required to show the report with these options.
* @return array URL parameter name => value.
*/
protected function get_url_params() {
$params = [
'id' => $this->cm->id,
'mode' => $this->mode,
'attempts' => $this->attempts,
'onlygraded' => $this->onlygraded,
];
if ($this->states) {
$params['states'] = implode('-', $this->states);
}
if (groups_get_activity_groupmode($this->cm, $this->course)) {
$params['group'] = $this->group;
}
return $params;
}
/**
* Get the URL to show the report with these options.
* @return moodle_url the URL.
*/
public function get_url() {
return new moodle_url('/mod/quiz/report.php', $this->get_url_params());
}
/**
* Process the data we get when the settings form is submitted. This includes
* updating the fields of this class, and updating the user preferences
* where appropriate.
* @param stdClass $fromform The data from $mform->get_data() from the settings form.
*/
public function process_settings_from_form($fromform) {
$this->setup_from_form_data($fromform);
$this->resolve_dependencies();
$this->update_user_preferences();
}
/**
* Set up this preferences object using optional_param (using user_preferences
* to set anything not specified by the params.
*/
public function process_settings_from_params() {
$this->setup_from_user_preferences();
$this->setup_from_params();
$this->resolve_dependencies();
}
/**
* Get the current value of the settings to pass to the settings form.
*/
public function get_initial_form_data() {
$toform = new stdClass();
$toform->attempts = $this->attempts;
$toform->onlygraded = $this->onlygraded;
$toform->pagesize = $this->pagesize;
if ($this->states) {
foreach (self::$statefields as $field => $state) {
$toform->$field = in_array($state, $this->states);
}
}
return $toform;
}
/**
* Set the fields of this object from the form data.
* @param stdClass $fromform The data from $mform->get_data() from the settings form.
*/
public function setup_from_form_data($fromform) {
$this->attempts = $fromform->attempts;
$this->group = groups_get_activity_group($this->cm, true);
$this->onlygraded = !empty($fromform->onlygraded);
$this->pagesize = $fromform->pagesize;
$this->states = [];
foreach (self::$statefields as $field => $state) {
if (!empty($fromform->$field)) {
$this->states[] = $state;
}
}
}
/**
* Set the fields of this object from the URL parameters.
*/
public function setup_from_params() {
$this->attempts = optional_param('attempts', $this->attempts, PARAM_ALPHAEXT);
$this->group = groups_get_activity_group($this->cm, true);
$this->onlygraded = optional_param('onlygraded', $this->onlygraded, PARAM_BOOL);
$this->pagesize = optional_param('pagesize', $this->pagesize, PARAM_INT);
$states = optional_param('states', '', PARAM_ALPHAEXT);
if (!empty($states)) {
$this->states = explode('-', $states);
}
$this->download = optional_param('download', $this->download, PARAM_ALPHA);
}
/**
* Set the fields of this object from the user's preferences.
* (For those settings that are backed by user-preferences).
*/
public function setup_from_user_preferences() {
$this->pagesize = get_user_preferences('quiz_report_pagesize', $this->pagesize);
}
/**
* Update the user preferences so they match the settings in this object.
* (For those settings that are backed by user-preferences).
*/
public function update_user_preferences() {
set_user_preference('quiz_report_pagesize', $this->pagesize);
}
/**
* Check the settings, and remove any 'impossible' combinations.
*/
public function resolve_dependencies() {
if ($this->group) {
// Default for when a group is selected.
if ($this->attempts === null || $this->attempts == attempts_report::ALL_WITH) {
$this->attempts = attempts_report::ENROLLED_WITH;
}
} else if (!$this->group && $this->course->id == SITEID) {
// Force report on front page to show all, unless a group is selected.
$this->attempts = attempts_report::ALL_WITH;
} else if (!in_array($this->attempts, [attempts_report::ALL_WITH, attempts_report::ENROLLED_WITH,
attempts_report::ENROLLED_WITHOUT, attempts_report::ENROLLED_ALL])) {
$this->attempts = attempts_report::ENROLLED_WITH;
}
$cleanstates = [];
foreach (self::$statefields as $state) {
if (in_array($state, $this->states)) {
$cleanstates[] = $state;
}
}
$this->states = $cleanstates;
if (count($this->states) == count(self::$statefields)) {
// If all states have been selected, then there is no constraint
// required in the SQL, so clear the array.
$this->states = null;
}
if (!quiz_report_can_filter_only_graded($this->quiz)) {
// A grading mode like 'average' has been selected, so we cannot do
// the show the attempt that gave the final grade thing.
$this->onlygraded = false;
}
if ($this->attempts == attempts_report::ENROLLED_WITHOUT) {
$this->states = null;
$this->onlygraded = false;
}
if (!$this->is_showing_finished_attempts()) {
$this->onlygraded = false;
}
if ($this->pagesize < 1) {
$this->pagesize = attempts_report::DEFAULT_PAGE_SIZE;
}
}
/**
* Whether the options are such that finished attempts are being shown.
* @return boolean
*/
protected function is_showing_finished_attempts() {
return $this->states === null || in_array(quiz_attempt::FINISHED, $this->states);
}
}
@@ -0,0 +1,136 @@
<?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 mod_quiz\local\reports;
use html_writer;
use MoodleQuickForm;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/formslib.php');
/**
* Base class for the settings form for {@see attempts_report}s.
*
* @package mod_quiz
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class attempts_report_options_form extends \moodleform {
protected function definition() {
$mform = $this->_form;
$mform->addElement('header', 'preferencespage',
get_string('reportwhattoinclude', 'quiz'));
$this->standard_attempt_fields($mform);
$this->other_attempt_fields($mform);
$mform->addElement('header', 'preferencesuser',
get_string('reportdisplayoptions', 'quiz'));
$this->standard_preference_fields($mform);
$this->other_preference_fields($mform);
$mform->addElement('submit', 'submitbutton',
get_string('showreport', 'quiz'));
}
/**
* Add the standard form fields for selecting which attempts to include in the report.
*
* @param MoodleQuickForm $mform the form we are building.
*/
protected function standard_attempt_fields(MoodleQuickForm $mform) {
$mform->addElement('select', 'attempts', get_string('reportattemptsfrom', 'quiz'), [
attempts_report::ENROLLED_WITH => get_string('reportuserswith', 'quiz'),
attempts_report::ENROLLED_WITHOUT => get_string('reportuserswithout', 'quiz'),
attempts_report::ENROLLED_ALL => get_string('reportuserswithorwithout', 'quiz'),
attempts_report::ALL_WITH => get_string('reportusersall', 'quiz'),
]);
$stategroup = [
$mform->createElement('advcheckbox', 'stateinprogress', '',
get_string('stateinprogress', 'quiz')),
$mform->createElement('advcheckbox', 'stateoverdue', '',
get_string('stateoverdue', 'quiz')),
$mform->createElement('advcheckbox', 'statefinished', '',
get_string('statefinished', 'quiz')),
$mform->createElement('advcheckbox', 'stateabandoned', '',
get_string('stateabandoned', 'quiz')),
];
$mform->addGroup($stategroup, 'stateoptions',
get_string('reportattemptsthatare', 'quiz'), [' '], false);
$mform->setDefault('stateinprogress', 1);
$mform->setDefault('stateoverdue', 1);
$mform->setDefault('statefinished', 1);
$mform->setDefault('stateabandoned', 1);
$mform->disabledIf('stateinprogress', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT);
$mform->disabledIf('stateoverdue', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT);
$mform->disabledIf('statefinished', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT);
$mform->disabledIf('stateabandoned', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT);
if (quiz_report_can_filter_only_graded($this->_customdata['quiz'])) {
$gm = html_writer::tag('span',
quiz_get_grading_option_name($this->_customdata['quiz']->grademethod),
['class' => 'highlight']);
$mform->addElement('advcheckbox', 'onlygraded', '',
get_string('reportshowonlyfinished', 'quiz', $gm));
$mform->disabledIf('onlygraded', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT);
$mform->disabledIf('onlygraded', 'statefinished', 'notchecked');
}
}
/**
* Extension point to allow subclasses to add their own fields in the attempts section.
*
* @param MoodleQuickForm $mform the form we are building.
*/
protected function other_attempt_fields(MoodleQuickForm $mform) {
}
/**
* Add the standard options fields to the form.
*
* @param MoodleQuickForm $mform the form we are building.
*/
protected function standard_preference_fields(MoodleQuickForm $mform) {
$mform->addElement('text', 'pagesize', get_string('pagesize', 'quiz'));
$mform->setType('pagesize', PARAM_INT);
}
/**
* Extension point to allow subclasses to add their own fields in the options section.
*
* @param MoodleQuickForm $mform the form we are building.
*/
protected function other_preference_fields(MoodleQuickForm $mform) {
}
public function validation($data, $files) {
$errors = parent::validation($data, $files);
if ($data['attempts'] != attempts_report::ENROLLED_WITHOUT && !(
$data['stateinprogress'] || $data['stateoverdue'] || $data['statefinished'] || $data['stateabandoned'])) {
$errors['stateoptions'] = get_string('reportmustselectstate', 'quiz');
}
return $errors;
}
}
@@ -0,0 +1,846 @@
<?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 mod_quiz\local\reports;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/tablelib.php');
use coding_exception;
use context_module;
use html_writer;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
use moodle_url;
use popup_action;
use question_state;
use qubaid_condition;
use qubaid_join;
use qubaid_list;
use question_engine_data_mapper;
use stdClass;
/**
* Base class for the table used by a {@see attempts_report}.
*
* @package mod_quiz
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class attempts_report_table extends \table_sql {
public $useridfield = 'userid';
/** @var moodle_url the URL of this report. */
protected $reporturl;
/** @var array the display options. */
protected $displayoptions;
/**
* @var array information about the latest step of each question.
* Loaded by {@see load_question_latest_steps()}, if applicable.
*/
protected $lateststeps = null;
/**
* @var float[][]|null total mark for each grade item. Array question_usage.id => quiz_grade_item.id => mark.
* Loaded by {@see load_grade_item_marks()}, if applicable.
*/
protected ?array $gradeitemtotals = null;
/** @var stdClass the quiz settings for the quiz we are reporting on. */
protected $quiz;
/** @var quiz_settings quiz settings object for this quiz. Gets set in {@see attempts_report::et_up_table_columns()}. */
protected quiz_settings $quizobj;
/** @var context_module the quiz context. */
protected $context;
/** @var string HTML fragment to select the first/best/last attempt, if appropriate. */
protected $qmsubselect;
/** @var stdClass attempts_report_options the options affecting this report. */
protected $options;
/** @var \core\dml\sql_join Contains joins, wheres, params to find students
* in the currently selected group, if applicable.
*/
protected $groupstudentsjoins;
/** @var \core\dml\sql_join Contains joins, wheres, params to find the students in the course. */
protected $studentsjoins;
/** @var array the questions that comprise this quiz. */
protected $questions;
/** @var bool whether to include the column with checkboxes to select each attempt. */
protected $includecheckboxes;
/** @var string The toggle group name for the checkboxes in the checkbox column. */
protected $togglegroup = 'quiz-attempts';
/** @var string strftime format. */
protected $strtimeformat;
/** @var bool|null used by {@see col_state()} to cache the has_capability result. */
protected $canreopen = null;
/**
* Constructor.
*
* @param string $uniqueid
* @param stdClass $quiz
* @param context_module $context
* @param string $qmsubselect
* @param attempts_report_options $options
* @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params
* @param \core\dml\sql_join $studentsjoins Contains joins, wheres, params
* @param array $questions
* @param moodle_url $reporturl
*/
public function __construct($uniqueid, $quiz, $context, $qmsubselect,
attempts_report_options $options, \core\dml\sql_join $groupstudentsjoins, \core\dml\sql_join $studentsjoins,
$questions, $reporturl) {
parent::__construct($uniqueid);
$this->quiz = $quiz;
$this->context = $context;
$this->qmsubselect = $qmsubselect;
$this->groupstudentsjoins = $groupstudentsjoins;
$this->studentsjoins = $studentsjoins;
$this->questions = $questions;
$this->includecheckboxes = $options->checkboxcolumn;
$this->reporturl = $reporturl;
$this->options = $options;
}
/**
* A way for the report to pass in the quiz settings object. Currently done in {@see attempts_report::set_up_table_columns()}.
*
* @param quiz_settings $quizobj
*/
public function set_quiz_setting(quiz_settings $quizobj): void {
$this->quizobj = $quizobj;
}
/**
* Generate the display of the checkbox column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_checkbox($attempt) {
global $OUTPUT;
if ($attempt->attempt) {
$checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, [
'id' => "attemptid_{$attempt->attempt}",
'name' => 'attemptid[]',
'value' => $attempt->attempt,
'label' => get_string('selectattempt', 'quiz'),
'labelclasses' => 'accesshide',
]);
return $OUTPUT->render($checkbox);
} else {
return '';
}
}
/**
* Generate the display of the user's picture column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_picture($attempt) {
global $OUTPUT;
$user = new stdClass();
$additionalfields = explode(',', implode(',', \core_user\fields::get_picture_fields()));
$user = username_load_fields_from_object($user, $attempt, null, $additionalfields);
$user->id = $attempt->userid;
return $OUTPUT->user_picture($user);
}
/**
* Generate the display of the user's full name column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_fullname($attempt) {
$html = parent::col_fullname($attempt);
if ($this->is_downloading() || empty($attempt->attempt)) {
return $html;
}
return $html . html_writer::empty_tag('br') . html_writer::link(
new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->attempt]),
get_string('reviewattempt', 'quiz'), ['class' => 'reviewlink']);
}
/**
* Generate the display of the attempt state column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_state($attempt) {
if (is_null($attempt->attempt)) {
return '-';
}
$display = quiz_attempt::state_name($attempt->state);
if ($this->is_downloading()) {
return $display;
}
$this->canreopen ??= has_capability('mod/quiz:reopenattempts', $this->context);
if ($attempt->state == quiz_attempt::ABANDONED && $this->canreopen) {
$display .= ' ' . html_writer::tag('button', get_string('reopenattempt', 'quiz'), [
'type' => 'button',
'class' => 'btn btn-secondary',
'data-action' => 'reopen-attempt',
'data-attempt-id' => $attempt->attempt,
'data-after-action-url' => $this->reporturl->out_as_local_url(false),
]);
}
return $display;
}
/**
* Generate the display of the start time column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_timestart($attempt) {
if ($attempt->attempt) {
return userdate($attempt->timestart, $this->strtimeformat);
} else {
return '-';
}
}
/**
* Generate the display of the finish time column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_timefinish($attempt) {
if ($attempt->attempt && $attempt->timefinish) {
return userdate($attempt->timefinish, $this->strtimeformat);
} else {
return '-';
}
}
/**
* Generate the display of the duration column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_duration($attempt) {
if ($attempt->timefinish) {
return format_time($attempt->timefinish - $attempt->timestart);
} else {
return '-';
}
}
/**
* Generate the display of the feedback column.
*
* @param stdClass $attempt the table row being output.
* @return string HTML content to go inside the td.
*/
public function col_feedbacktext($attempt) {
if ($attempt->state != quiz_attempt::FINISHED) {
return '-';
}
$feedback = quiz_report_feedback_for_grade(
quiz_rescale_grade($attempt->sumgrades, $this->quiz, false),
$this->quiz->id, $this->context);
if ($this->is_downloading()) {
$feedback = strip_tags($feedback);
}
return $feedback;
}
public function other_cols($colname, $attempt) {
$gradeitemid = $this->is_grade_item_column($colname);
if (!$gradeitemid) {
return parent::other_cols($colname, $attempt);
}
if (isset($this->gradeitemtotals[$attempt->usageid][$gradeitemid])) {
$grade = quiz_format_grade($this->quiz, $this->gradeitemtotals[$attempt->usageid][$gradeitemid]);
return $grade;
} else {
return '-';
}
}
/**
* Is this the column key for an extra grade item column?
*
* @param string $columnname e.g. 'marks123' or 'duration'.
* @return int grade item id if this is a column for showing that grade item grade, else, 0.
*/
protected function is_grade_item_column(string $columnname): int {
if (preg_match('/^marks(\d+)$/', $columnname, $matches)) {
return $matches[1];
}
return 0;
}
public function get_row_class($attempt) {
if ($this->qmsubselect && $attempt->gradedattempt) {
return 'gradedattempt';
} else {
return '';
}
}
/**
* Make a link to review an individual question in a popup window.
*
* @param string $data HTML fragment. The text to make into the link.
* @param stdClass $attempt data for the row of the table being output.
* @param int $slot the number used to identify this question within this usage.
*/
public function make_review_link($data, $attempt, $slot) {
global $OUTPUT, $CFG;
$flag = '';
if ($this->is_flagged($attempt->usageid, $slot)) {
$flag = $OUTPUT->pix_icon('i/flagged', get_string('flagged', 'question'),
'moodle', ['class' => 'questionflag']);
}
$feedbackimg = '';
$state = $this->slot_state($attempt, $slot);
if ($state && $state->is_finished() && $state != question_state::$needsgrading) {
$feedbackimg = $this->icon_for_fraction($this->slot_fraction($attempt, $slot));
}
$output = html_writer::tag('span', $feedbackimg . html_writer::tag('span',
$data, ['class' => $state->get_state_class(true)]) . $flag, ['class' => 'que']);
$reviewparams = ['attempt' => $attempt->attempt, 'slot' => $slot];
if (isset($attempt->try)) {
$reviewparams['step'] = $this->step_no_for_try($attempt->usageid, $slot, $attempt->try);
}
$url = new moodle_url('/mod/quiz/reviewquestion.php', $reviewparams);
$output = $OUTPUT->action_link($url, $output,
new popup_action('click', $url, 'reviewquestion',
['height' => 450, 'width' => 650]),
['title' => get_string('reviewresponse', 'quiz')]);
if (!empty($CFG->enableplagiarism)) {
require_once($CFG->libdir . '/plagiarismlib.php');
$output .= plagiarism_get_links([
'context' => $this->context->id,
'component' => 'qtype_'.$this->questions[$slot]->qtype,
'cmid' => $this->context->instanceid,
'area' => $attempt->usageid,
'itemid' => $slot,
'userid' => $attempt->userid]);
}
return $output;
}
/**
* Get the question attempt state for a particular question in a particular quiz attempt.
*
* @param stdClass $attempt the row data.
* @param int $slot indicates which question.
* @return question_state the state of that question.
*/
protected function slot_state($attempt, $slot) {
$stepdata = $this->lateststeps[$attempt->usageid][$slot];
return question_state::get($stepdata->state);
}
/**
* Work out if a particular question in a particular attempt has been flagged.
*
* @param int $questionusageid used to identify the attempt of interest.
* @param int $slot identifies which question in the attempt to check.
* @return bool true if the question is flagged in the attempt.
*/
protected function is_flagged($questionusageid, $slot) {
$stepdata = $this->lateststeps[$questionusageid][$slot];
return $stepdata->flagged;
}
/**
* Get the mark (out of 1) for the question in a particular slot.
*
* @param stdClass $attempt the row data
* @param int $slot which slot to check.
* @return float the score for this question on a scale of 0 - 1.
*/
protected function slot_fraction($attempt, $slot) {
$stepdata = $this->lateststeps[$attempt->usageid][$slot];
return $stepdata->fraction;
}
/**
* Return an appropriate icon (green tick, red cross, etc.) for a grade.
*
* @param float $fraction grade on a scale 0..1.
* @return string html fragment.
*/
protected function icon_for_fraction($fraction) {
global $OUTPUT;
$feedbackclass = question_state::graded_state_for_fraction($fraction)->get_feedback_class();
return $OUTPUT->pix_icon('i/grade_' . $feedbackclass, get_string($feedbackclass, 'question'),
'moodle', ['class' => 'icon']);
}
/**
* Load any extra data after main query.
*
* At this point you can call {@see get_qubaids_condition} to get the condition
* that limits the query to just the question usages shown in this report page or
* alternatively for all attempts if downloading a full report.
*/
protected function load_extra_data() {
$this->lateststeps = $this->load_question_latest_steps();
}
/**
* Load the total mark for each grade item for each attempt.
*/
protected function load_grade_item_marks(): void {
if (count($this->rawdata) === 0) {
$this->gradeitemtotals = [];
return;
}
$this->gradeitemtotals = $this->quizobj->get_grade_calculator()->load_grade_item_totals(
$this->get_qubaids_condition());
}
/**
* Load information about the latest state of selected questions in selected attempts.
*
* The results are returned as a two-dimensional array $qubaid => $slot => $dataobject.
*
* @param qubaid_condition|null $qubaids used to restrict which usages are included
* in the query. See {@see qubaid_condition}.
* @return array of records. See the SQL in this function to see the fields available.
*/
protected function load_question_latest_steps(qubaid_condition $qubaids = null) {
if ($qubaids === null) {
$qubaids = $this->get_qubaids_condition();
}
$dm = new question_engine_data_mapper();
$latesstepdata = $dm->load_questions_usages_latest_steps(
$qubaids, array_keys($this->questions));
$lateststeps = [];
foreach ($latesstepdata as $step) {
$lateststeps[$step->questionusageid][$step->slot] = $step;
}
return $lateststeps;
}
/**
* Does this report require loading any more data after the main query.
*
* @return bool should {@see query_db()} call {@see load_extra_data}?
*/
protected function requires_extra_data() {
return $this->requires_latest_steps_loaded();
}
/**
* Does this report require the detailed information for each question from the question_attempts_steps table?
*
* @return bool should {@see load_extra_data} call {@see load_question_latest_steps}?
*/
protected function requires_latest_steps_loaded() {
return false;
}
/**
* Is this a column that depends on joining to the latest state information?
*
* If so, return the corresponding slot. If not, return false.
*
* @param string $column a column name
* @return int|false false if no, else a slot.
*/
protected function is_latest_step_column($column) {
return false;
}
/**
* Get any fields that might be needed when sorting on date for a particular slot.
*
* Note: these values are only used for sorting. The values displayed are taken
* from $this->lateststeps loaded in load_extra_data().
*
* @param int $slot the slot for the column we want.
* @param string $alias the table alias for latest state information relating to that slot.
* @return string definitions of extra fields to add to the SELECT list of the query.
*/
protected function get_required_latest_state_fields($slot, $alias) {
return '';
}
/**
* Contruct all the parts of the main database query.
*
* @param \core\dml\sql_join $allowedstudentsjoins (joins, wheres, params) defines allowed users for the report.
* @return array with 4 elements [$fields, $from, $where, $params] that can be used to
* build the actual database query.
*/
public function base_sql(\core\dml\sql_join $allowedstudentsjoins) {
global $DB;
// Please note this uniqueid column is not the same as quiza.uniqueid.
$fields = 'DISTINCT ' . $DB->sql_concat('u.id', "'#'", 'COALESCE(quiza.attempt, 0)') . ' AS uniqueid,';
if ($this->qmsubselect) {
$fields .= "\n(CASE WHEN $this->qmsubselect THEN 1 ELSE 0 END) AS gradedattempt,";
}
$userfieldsapi = \core_user\fields::for_identity($this->context)->with_name()
->excluding('id', 'idnumber', 'picture', 'imagealt', 'institution', 'department', 'email');
$userfields = $userfieldsapi->get_sql('u', true, '', '', false);
$fields .= '
quiza.uniqueid AS usageid,
quiza.id AS attempt,
u.id AS userid,
u.idnumber,
u.picture,
u.imagealt,
u.institution,
u.department,
u.email,' . $userfields->selects . ',
quiza.state,
quiza.sumgrades,
quiza.timefinish,
quiza.timestart,
CASE WHEN quiza.timefinish = 0 THEN null
WHEN quiza.timefinish > quiza.timestart THEN quiza.timefinish - quiza.timestart
ELSE 0 END AS duration';
// To explain that last bit, timefinish can be non-zero and less
// than timestart when you have two load-balanced servers with very
// badly synchronised clocks, and a student does a really quick attempt.
// This part is the same for all cases. Join the users and quiz_attempts tables.
$from = " {user} u";
$from .= "\n{$userfields->joins}";
$from .= "\nLEFT JOIN {quiz_attempts} quiza ON
quiza.userid = u.id AND quiza.quiz = :quizid";
$params = array_merge($userfields->params, ['quizid' => $this->quiz->id]);
if ($this->qmsubselect && $this->options->onlygraded) {
$from .= " AND (quiza.state <> :finishedstate OR $this->qmsubselect)";
$params['finishedstate'] = quiz_attempt::FINISHED;
}
switch ($this->options->attempts) {
case attempts_report::ALL_WITH:
// Show all attempts, including students who are no longer in the course.
$where = 'quiza.id IS NOT NULL AND quiza.preview = 0';
break;
case attempts_report::ENROLLED_WITH:
// Show only students with attempts.
$from .= "\n" . $allowedstudentsjoins->joins;
$where = "quiza.preview = 0 AND quiza.id IS NOT NULL AND " . $allowedstudentsjoins->wheres;
$params = array_merge($params, $allowedstudentsjoins->params);
break;
case attempts_report::ENROLLED_WITHOUT:
// Show only students without attempts.
$from .= "\n" . $allowedstudentsjoins->joins;
$where = "quiza.id IS NULL AND " . $allowedstudentsjoins->wheres;
$params = array_merge($params, $allowedstudentsjoins->params);
break;
case attempts_report::ENROLLED_ALL:
// Show all students with or without attempts.
$from .= "\n" . $allowedstudentsjoins->joins;
$where = "(quiza.preview = 0 OR quiza.preview IS NULL) AND " . $allowedstudentsjoins->wheres;
$params = array_merge($params, $allowedstudentsjoins->params);
break;
}
if ($this->options->states) {
[$statesql, $stateparams] = $DB->get_in_or_equal($this->options->states,
SQL_PARAMS_NAMED, 'state');
$params += $stateparams;
$where .= " AND (quiza.state $statesql OR quiza.state IS NULL)";
}
return [$fields, $from, $where, $params];
}
/**
* Lets subclasses modify the SQL after the count query has been created and before the full query is.
*
* @param string $fields SELECT list.
* @param string $from JOINs part of the SQL.
* @param string $where WHERE clauses.
* @param array $params Query params.
* @return array with 4 elements ($fields, $from, $where, $params) as from base_sql.
*/
protected function update_sql_after_count($fields, $from, $where, $params) {
return [$fields, $from, $where, $params];
}
/**
* Set up the SQL queries (count rows, and get data).
*
* @param \core\dml\sql_join $allowedjoins (joins, wheres, params) defines allowed users for the report.
*/
public function setup_sql_queries($allowedjoins) {
[$fields, $from, $where, $params] = $this->base_sql($allowedjoins);
// The WHERE clause is vital here, because some parts of tablelib.php will expect to
// add bits like ' AND x = 1' on the end, and that needs to leave to valid SQL.
$this->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
[$fields, $from, $where, $params] = $this->update_sql_after_count($fields, $from, $where, $params);
$this->set_sql($fields, $from, $where, $params);
}
/**
* Add the information about the latest state of the question with slot
* $slot to the query.
*
* The extra information is added as a join to a
* 'table' with alias qa$slot, with columns that are a union of
* the columns of the question_attempts and question_attempts_states tables.
*
* @param int $slot the question to add information for.
*/
protected function add_latest_state_join($slot) {
$alias = 'qa' . $slot;
$fields = $this->get_required_latest_state_fields($slot, $alias);
if (!$fields) {
return;
}
// This condition roughly filters the list of attempts to be considered.
// It is only used in a sub-select to help database query optimisers (see MDL-30122).
// Therefore, it is better to use a very simple which may include
// too many records, than to do a super-accurate join.
$qubaids = new qubaid_join("{quiz_attempts} {$alias}quiza", "{$alias}quiza.uniqueid",
"{$alias}quiza.quiz = :{$alias}quizid", ["{$alias}quizid" => $this->sql->params['quizid']]);
$dm = new question_engine_data_mapper();
[$inlineview, $viewparams] = $dm->question_attempt_latest_state_view($alias, $qubaids);
$this->sql->fields .= ",\n$fields";
$this->sql->from .= "\nLEFT JOIN $inlineview ON " .
"$alias.questionusageid = quiza.uniqueid AND $alias.slot = :{$alias}slot";
$this->sql->params[$alias . 'slot'] = $slot;
$this->sql->params = array_merge($this->sql->params, $viewparams);
}
/**
* Add a field marks$gradeitemid to the query, with the total score for that grade item.
*
* @param int $gradeitemid the grade item to add information for.
*/
protected function add_grade_item_mark(int $gradeitemid): void {
$dm = new question_engine_data_mapper();
$alias = 'gimarks' . $gradeitemid;
$this->sql->fields .= ",\n(
SELECT SUM({$alias}qas.fraction * {$alias}qa.maxmark) AS summarks
FROM {quiz_slots} {$alias}slot
JOIN {question_attempts} {$alias}qa ON {$alias}qa.slot = {$alias}slot.slot
JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
AND {$alias}qas.sequencenumber = {$dm->latest_step_for_qa_subquery("{$alias}qa.id")}
WHERE {$alias}qa.questionusageid = quiza.uniqueid
AND {$alias}slot.quizgradeitemid = :{$alias}gradeitemid
) AS marks$gradeitemid";
$this->sql->params[$alias . 'gradeitemid'] = $gradeitemid;
}
/**
* Get an appropriate qubaid_condition for loading more data about the attempts we are displaying.
*
* @return qubaid_condition
*/
protected function get_qubaids_condition() {
if (is_null($this->rawdata)) {
throw new coding_exception(
'Cannot call get_qubaids_condition until the main data has been loaded.');
}
if ($this->is_downloading()) {
// We want usages for all attempts.
return new qubaid_join("(
SELECT DISTINCT quiza.uniqueid
FROM " . $this->sql->from . "
WHERE " . $this->sql->where . "
) quizasubquery", 'quizasubquery.uniqueid',
"1 = 1", $this->sql->params);
}
$qubaids = [];
foreach ($this->rawdata as $attempt) {
if ($attempt->usageid > 0) {
$qubaids[] = $attempt->usageid;
}
}
return new qubaid_list($qubaids);
}
public function query_db($pagesize, $useinitialsbar = true) {
$doneslots = [];
$donegradeitems = [];
foreach ($this->get_sort_columns() as $column => $notused) {
$slot = $this->is_latest_step_column($column);
if ($slot && !in_array($slot, $doneslots)) {
$this->add_latest_state_join($slot);
$doneslots[] = $slot;
}
$gradeitemid = $this->is_grade_item_column($column);
if ($gradeitemid && !in_array($gradeitemid, $donegradeitems)) {
$this->add_grade_item_mark($gradeitemid);
$donegradeitems[] = $gradeitemid;
}
}
parent::query_db($pagesize, $useinitialsbar);
// Load grade-item totals if required.
foreach ($this->columns as $columnname => $notused) {
if ($this->is_grade_item_column($columnname)) {
$this->load_grade_item_marks();
break;
}
}
if ($this->requires_extra_data()) {
$this->load_extra_data();
}
}
public function get_sort_columns() {
// Add attemptid as a final tie-break to the sort. This ensures that
// Attempts by the same student appear in order when just sorting by name.
$sortcolumns = parent::get_sort_columns();
$sortcolumns['quiza.id'] = SORT_ASC;
return $sortcolumns;
}
public function wrap_html_start() {
if ($this->is_downloading() || !$this->includecheckboxes) {
return;
}
$url = $this->options->get_url();
$url->param('sesskey', sesskey());
echo '<div id="tablecontainer">';
echo '<form id="attemptsform" method="post" action="' . $url->out_omit_querystring() . '">';
echo html_writer::input_hidden_params($url);
echo '<div>';
}
public function wrap_html_finish() {
global $PAGE;
if ($this->is_downloading() || !$this->includecheckboxes) {
return;
}
echo '<div id="commands">';
$this->submit_buttons();
echo '</div>';
// Close the form.
echo '</div>';
echo '</form></div>';
}
/**
* Output any submit buttons required by the $this->includecheckboxes form.
*/
protected function submit_buttons() {
global $PAGE;
if (has_capability('mod/quiz:deleteattempts', $this->context)) {
$deletebuttonparams = [
'type' => 'submit',
'class' => 'btn btn-secondary mr-1',
'id' => 'deleteattemptsbutton',
'name' => 'delete',
'value' => get_string('deleteselected', 'quiz_overview'),
'data-action' => 'toggle',
'data-togglegroup' => $this->togglegroup,
'data-toggle' => 'action',
'disabled' => true,
'data-modal' => 'confirmation',
'data-modal-type' => 'delete',
'data-modal-content-str' => json_encode(['deleteattemptcheck', 'quiz']),
];
echo html_writer::empty_tag('input', $deletebuttonparams);
}
}
/**
* Generates the contents for the checkbox column header.
*
* It returns the HTML for a master \core\output\checkbox_toggleall component that selects/deselects all quiz attempts.
*
* @param string $columnname The name of the checkbox column.
* @return string
*/
public function checkbox_col_header(string $columnname) {
global $OUTPUT;
// Make sure to disable sorting on this column.
$this->no_sorting($columnname);
// Build the select/deselect all control.
$selectallid = $this->uniqueid . '-selectall-attempts';
$selectalltext = get_string('selectall', 'quiz');
$deselectalltext = get_string('selectnone', 'quiz');
$mastercheckbox = new \core\output\checkbox_toggleall($this->togglegroup, true, [
'id' => $selectallid,
'name' => $selectallid,
'value' => 1,
'label' => $selectalltext,
'labelclasses' => 'accesshide',
'selectall' => $selectalltext,
'deselectall' => $deselectalltext,
]);
return $OUTPUT->render($mastercheckbox);
}
}
@@ -0,0 +1,96 @@
<?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 mod_quiz\local\reports;
use context;
use context_module;
use stdClass;
/**
* Base class for quiz report plugins.
*
* Doesn't do anything on its own -- it needs to be extended.
* This class displays quiz reports. Because it is called from
* within /mod/quiz/report.php you can assume that the page header
* and footer are taken care of.
*
* This file can refer to itself as report.php to pass variables
* to itself - all these will also be globally available. You must
* pass "id=$cm->id" or q=$quiz->id", and "mode=reportname".
*
* @package mod_quiz
* @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class report_base {
/** @var int special value used in place of groupid, to mean the use cannot access any groups. */
const NO_GROUPS_ALLOWED = -2;
/**
* Override this function to display the report.
*
* @param stdClass $quiz this quiz.
* @param stdClass $cm the course-module for this quiz.
* @param stdClass $course the coures we are in.
*/
abstract public function display($quiz, $cm, $course);
/**
* Initialise some parts of $PAGE and start output.
*
* @param stdClass $cm the course_module information.
* @param stdClass $course the course settings.
* @param stdClass $quiz the quiz settings.
* @param string $reportmode the report name.
*/
public function print_header_and_tabs($cm, $course, $quiz, $reportmode = 'overview') {
global $PAGE, $OUTPUT, $CFG;
// Print the page header.
$PAGE->set_title($quiz->name);
$PAGE->set_heading($course->fullname);
echo $OUTPUT->header();
$context = context_module::instance($cm->id);
if (!$PAGE->has_secondary_navigation()) {
echo $OUTPUT->heading(format_string($quiz->name, true, ['context' => $context]));
}
if (!empty($CFG->enableplagiarism)) {
require_once($CFG->libdir . '/plagiarismlib.php');
echo plagiarism_update_status($course, $cm);
}
}
/**
* Get the current group for the user user looking at the report.
*
* @param stdClass $cm the course_module information.
* @param stdClass $course the course settings.
* @param context $context the quiz context.
* @return int the current group id, if applicable. 0 for all users,
* NO_GROUPS_ALLOWED if the user cannot see any group.
*/
public function get_current_group($cm, $course, $context) {
$groupmode = groups_get_activity_groupmode($cm, $course);
$currentgroup = groups_get_activity_group($cm, true);
if ($groupmode == SEPARATEGROUPS && !$currentgroup && !has_capability('moodle/site:accessallgroups', $context)) {
$currentgroup = self::NO_GROUPS_ALLOWED;
}
return $currentgroup;
}
}
@@ -0,0 +1,232 @@
<?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 mod_quiz\local\structure;
use context_module;
/**
* Class slot_random, represents a random question slot type.
*
* @package mod_quiz
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class slot_random {
/** @var \stdClass Slot's properties. A record retrieved from the quiz_slots table. */
protected $record;
/**
* @var \stdClass set reference record
*/
protected $referencerecord;
/**
* @var \stdClass The quiz this question slot belongs to.
*/
protected $quiz = null;
/**
* @var \core_tag_tag[] List of tags for this slot.
*/
protected $tags = [];
/**
* @var string filter condition
*/
protected $filtercondition = null;
/**
* slot_random constructor.
*
* @param \stdClass $slotrecord Represents a record in the quiz_slots table.
*/
public function __construct($slotrecord = null) {
$this->record = new \stdClass();
$this->referencerecord = new \stdClass();
$slotproperties = ['id', 'slot', 'quizid', 'page', 'requireprevious', 'maxmark'];
$setreferenceproperties = ['usingcontextid', 'questionscontextid'];
foreach ($slotproperties as $property) {
if (isset($slotrecord->$property)) {
$this->record->$property = $slotrecord->$property;
}
}
foreach ($setreferenceproperties as $referenceproperty) {
if (isset($slotrecord->$referenceproperty)) {
$this->referencerecord->$referenceproperty = $slotrecord->$referenceproperty;
}
}
}
/**
* Returns the quiz for this question slot.
* The quiz is fetched the first time it is requested and then stored in a member variable to be returned each subsequent time.
*
* @return mixed
* @throws \coding_exception
*/
public function get_quiz() {
global $DB;
if (empty($this->quiz)) {
if (empty($this->record->quizid)) {
throw new \coding_exception('quizid is not set.');
}
$this->quiz = $DB->get_record('quiz', ['id' => $this->record->quizid]);
}
return $this->quiz;
}
/**
* Sets the quiz object for the quiz slot.
* It is not mandatory to set the quiz as the quiz slot can fetch it the first time it is accessed,
* however it helps with the performance to set the quiz if you already have it.
*
* @param \stdClass $quiz The qui object.
*/
public function set_quiz($quiz) {
$this->quiz = $quiz;
$this->record->quizid = $quiz->id;
}
/**
* Set some tags for this quiz slot.
*
* @param \core_tag_tag[] $tags
*
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function set_tags($tags) {
debugging('Method set_tags() is deprecated, ' .
'please do not use this function.', DEBUG_DEVELOPER);
$this->tags = [];
foreach ($tags as $tag) {
// We use $tag->id as the key for the array so not only it handles duplicates of the same tag being given,
// but also it is consistent with the behaviour of set_tags_by_id() below.
$this->tags[$tag->id] = $tag;
}
}
/**
* Set some tags for this quiz slot. This function uses tag ids to find tags.
*
* @param int[] $tagids
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function set_tags_by_id($tagids) {
debugging(
'Method set_tags_by_id() is deprecated, please do not use this function.',
DEBUG_DEVELOPER
);
$this->tags = \core_tag_tag::get_bulk($tagids, 'id, name');
}
/**
* Set filter condition.
*
* @param \string $filters
*/
public function set_filter_condition(string $filters): void {
$this->filtercondition = $filters;
}
/**
* Inserts the quiz slot at the $page page.
* It is required to call this function if you are building a quiz slot object from scratch.
*
* @param int $page The page that this slot will be inserted at.
*/
public function insert($page) {
global $DB;
$slots = $DB->get_records('quiz_slots', ['quizid' => $this->record->quizid],
'slot', 'id, slot, page');
$quiz = $this->get_quiz();
$trans = $DB->start_delegated_transaction();
$maxpage = 1;
$numonlastpage = 0;
foreach ($slots as $slot) {
if ($slot->page > $maxpage) {
$maxpage = $slot->page;
$numonlastpage = 1;
} else {
$numonlastpage += 1;
}
}
if (is_int($page) && $page >= 1) {
// Adding on a given page.
$lastslotbefore = 0;
foreach (array_reverse($slots) as $otherslot) {
if ($otherslot->page > $page) {
$DB->set_field('quiz_slots', 'slot', $otherslot->slot + 1, ['id' => $otherslot->id]);
} else {
$lastslotbefore = $otherslot->slot;
break;
}
}
$this->record->slot = $lastslotbefore + 1;
$this->record->page = min($page, $maxpage + 1);
quiz_update_section_firstslots($this->record->quizid, 1, max($lastslotbefore, 1));
} else {
$lastslot = end($slots);
if ($lastslot) {
$this->record->slot = $lastslot->slot + 1;
} else {
$this->record->slot = 1;
}
if ($quiz->questionsperpage && $numonlastpage >= $quiz->questionsperpage) {
$this->record->page = $maxpage + 1;
} else {
$this->record->page = $maxpage;
}
}
$this->record->id = $DB->insert_record('quiz_slots', $this->record);
$this->referencerecord->component = 'mod_quiz';
$this->referencerecord->questionarea = 'slot';
$this->referencerecord->itemid = $this->record->id;
$this->referencerecord->filtercondition = $this->filtercondition;
$DB->insert_record('question_set_references', $this->referencerecord);
$trans->allow_commit();
// Log slot created event.
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
$event = \mod_quiz\event\slot_created::create([
'context' => context_module::instance($cm->id),
'objectid' => $this->record->id,
'other' => [
'quizid' => $quiz->id,
'slotnumber' => $this->record->slot,
'page' => $this->record->page
]
]);
$event->trigger();
}
}