MDL-80300 mod_quiz: Add override manager class

This commit is contained in:
Matthew Hilton 2024-02-05 10:06:35 +10:00
parent 70f611116d
commit 2c6a746578
No known key found for this signature in database
GPG Key ID: DEE897B8DA89460B
18 changed files with 2004 additions and 217 deletions

View File

@ -64,6 +64,11 @@ class overrides implements \cache_data_source {
public function load_for_cache($key) {
global $DB;
// Ignore getting data if this is a cache invalidation - {@see \cache_helper::purge_by_event()}.
if ($key == 'lastinvalidation') {
return null;
}
[$quizid, $ug, $ugid] = explode('_', $key);
$quizid = (int) $quizid;

View File

@ -0,0 +1,127 @@
<?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);
}
}
}

View File

@ -0,0 +1,604 @@
<?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;
/**
* 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 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;
[$sql, $params] = self::get_override_in_sql($this->quiz->id, $ids);
$records = $DB->get_records_select('quiz_overrides', $sql, $params, '', 'id,userid,groupid');
// 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', '');
}
}
/**
* 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'));
}
}

View File

@ -365,8 +365,8 @@ class provider implements
[$quizobj]
);
// Delete all overrides - do not log.
quiz_delete_all_overrides($quiz, false);
// Delete all overrides.
$quizobj->get_override_manager()->delete_all_overrides(shouldlog: false);
// This will delete all question attempts, quiz attempts, and quiz grades for this quiz.
quiz_delete_all_attempts($quiz);
@ -411,9 +411,11 @@ class provider implements
'userid' => $user->id,
]);
foreach ($overrides as $override) {
quiz_delete_override($quiz, $override->id, false);
}
$manager = $quizobj->get_override_manager();
$manager->delete_overrides(
overrides: $overrides,
shouldlog: false,
);
// This will delete all question attempts, quiz attempts, and quiz grades for this quiz.
quiz_delete_user_attempts($quizobj, $user);
@ -461,9 +463,11 @@ class provider implements
'userid' => $userid,
]);
foreach ($overrides as $override) {
quiz_delete_override($quiz, $override->id, false);
}
$manager = $quizobj->get_override_manager();
$manager->delete_overrides(
overrides: $overrides,
shouldlog: false,
);
// This will delete all question attempts, quiz attempts, and quiz grades for this user in the given quiz.
quiz_delete_user_attempts($quizobj, (object)['id' => $userid]);

View File

@ -622,4 +622,16 @@ class quiz_settings {
return $questiontypes;
}
/**
* Returns an override manager instance with context and quiz loaded.
*
* @return \mod_quiz\local\override_manager
*/
public function get_override_manager(): \mod_quiz\local\override_manager {
return new \mod_quiz\local\override_manager(
quiz: $this->quiz,
context: $this->context
);
}
}

View File

@ -31,5 +31,8 @@ $definitions = [
'mode' => cache_store::MODE_APPLICATION,
'simplekeys' => true,
'datasource' => '\mod_quiz\cache\overrides',
'invalidationevents' => [
\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET,
],
],
];

View File

@ -311,3 +311,37 @@ function quiz_calculate_best_attempt($quiz, $attempts) {
return $maxattempt;
}
}
/**
* Deletes a quiz override from the database and clears any corresponding calendar events
*
* @deprecated since Moodle 4.4
* @todo MDL-80944 Final deprecation in Moodle 4.8
* @param stdClass $quiz The quiz object.
* @param int $overrideid The id of the override being deleted
* @param bool $log Whether to trigger logs.
* @return bool true on success
*/
function quiz_delete_override($quiz, $overrideid, $log = true) {
debugging('quiz_delete_override is deprecated. Please use override_manager::delete_override_by_id instead.', DEBUG_DEVELOPER);
$quizsettings = quiz_settings::create($quiz->id);
$quizsettings->get_override_manager()->delete_overrides_by_id(
ids: [$overrideid],
shouldlog: $log,
);
}
/**
* Deletes all quiz overrides from the database and clears any corresponding calendar events
*
* @deprecated since Moodle 4.4
* @todo MDL-80944 Final deprecation in Moodle 4.8
* @param stdClass $quiz The quiz object.
* @param bool $log Whether to trigger logs.
*/
function quiz_delete_all_overrides($quiz, $log = true) {
debugging('quiz_delete_all_overrides is deprecated. Please use override_manager::delete_all_overrides instead.',
DEBUG_DEVELOPER);
$quizsettings = quiz_settings::create($quiz->id);
$quizsettings->get_override_manager()->delete_all_overrides(shouldlog: $log);
}

View File

@ -176,7 +176,7 @@ $string['categorynoedit'] = 'You do not have editing privileges in the category
$string['categoryupdated'] = 'The category was successfully updated';
$string['close'] = 'Close window';
$string['closed'] = 'Closed';
$string['closebeforeopen'] = 'Could not update the quiz. You have specified a close date before the open date.';
$string['closebeforeopen'] = 'The close date cannot be before or equal to the open date.';
$string['closepreview'] = 'Close preview';
$string['closereview'] = 'Close review';
$string['comment'] = 'Comment';
@ -650,10 +650,21 @@ $string['overduehandlinggraceperiod'] = 'There is a grace period when open attem
$string['overduehandlingautoabandon'] = 'Attempts must be submitted before time expires, or they are not counted';
$string['overduemustbesubmittedby'] = 'This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {$a}. If you do not submit it by then, no marks from this attempt will be counted.';
$string['override'] = 'Override';
$string['overridecannotchange'] = 'The user or group cannot be changed after an override is created';
$string['overridecannotsetbothgroupanduser'] = 'Both group and user cannot be set at the same time';
$string['overridedeletegroupsure'] = 'Are you sure you want to delete the override for group {$a}?';
$string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?';
$string['overridegroup'] = 'Override group';
$string['overridegroupeventname'] = '{$a->quiz} - {$a->group}';
$string['overrideinvalidattempts'] = 'Attempts value must be greater than zero';
$string['overrideinvalidexistingid'] = 'Existing override does not exist';
$string['overrideinvalidgroup'] = 'Group given does not exist';
$string['overrideinvalidquiz'] = 'Quiz id set does not exist';
$string['overrideinvalidtimelimit'] = 'Time limit must be greater than zero';
$string['overrideinvaliduser'] = 'User given does not exist';
$string['overridemissingdelete'] = 'Override id(s) {$a} could not be deleted because they do not exist or are not a part of the given quiz';
$string['overridemultiplerecordsexist'] = 'Multiple overrides cannot be made for the same user/group';
$string['overridemustsetuserorgroup'] = 'A user or group must be set';
$string['overrides'] = 'Overrides';
$string['overridesforquiz'] = 'Settings overrides: {$a}';
$string['overridesnoneforgroups'] = 'No group settings overrides have been created for this quiz.';

View File

@ -38,6 +38,7 @@ use mod_quiz\question\display_options;
use mod_quiz\question\qubaids_for_quiz;
use mod_quiz\question\qubaids_for_users_attempts;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
use mod_quiz\local\override_cache;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
@ -190,7 +191,11 @@ function quiz_delete_instance($id) {
$quiz = $DB->get_record('quiz', ['id' => $id], '*', MUST_EXIST);
quiz_delete_all_attempts($quiz);
quiz_delete_all_overrides($quiz);
// Delete all overrides, and for performance do not log or check permissions.
$quizobj = quiz_settings::create($quiz->id);
$quizobj->get_override_manager()->delete_all_overrides(shouldlog: false);
quiz_delete_references($quiz->id);
// We need to do the following deletes before we try and delete randoms, otherwise they would still be 'in use'.
@ -214,86 +219,6 @@ function quiz_delete_instance($id) {
return true;
}
/**
* Deletes a quiz override from the database and clears any corresponding calendar events
*
* @param stdClass $quiz The quiz object.
* @param int $overrideid The id of the override being deleted
* @param bool $log Whether to trigger logs.
* @return bool true on success
*/
function quiz_delete_override($quiz, $overrideid, $log = true) {
global $DB;
if (!isset($quiz->cmid)) {
$cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
$quiz->cmid = $cm->id;
}
$override = $DB->get_record('quiz_overrides', ['id' => $overrideid], '*', MUST_EXIST);
// Delete the events.
if (isset($override->groupid)) {
// Create the search array for a group override.
$eventsearcharray = ['modulename' => 'quiz',
'instance' => $quiz->id, 'groupid' => (int)$override->groupid];
$cachekey = "{$quiz->id}_g_{$override->groupid}";
} else {
// Create the search array for a user override.
$eventsearcharray = ['modulename' => 'quiz',
'instance' => $quiz->id, 'userid' => (int)$override->userid];
$cachekey = "{$quiz->id}_u_{$override->userid}";
}
$events = $DB->get_records('event', $eventsearcharray);
foreach ($events as $event) {
$eventold = calendar_event::load($event);
$eventold->delete();
}
$DB->delete_records('quiz_overrides', ['id' => $overrideid]);
cache::make('mod_quiz', 'overrides')->delete($cachekey);
if ($log) {
// Set the common parameters for one of the events we will be triggering.
$params = [
'objectid' => $override->id,
'context' => context_module::instance($quiz->cmid),
'other' => [
'quizid' => $override->quiz
]
];
// Determine which override deleted event to fire.
if (!empty($override->userid)) {
$params['relateduserid'] = $override->userid;
$event = \mod_quiz\event\user_override_deleted::create($params);
} else {
$params['other']['groupid'] = $override->groupid;
$event = \mod_quiz\event\group_override_deleted::create($params);
}
// Trigger the override deleted event.
$event->add_record_snapshot('quiz_overrides', $override);
$event->trigger();
}
return true;
}
/**
* Deletes all quiz overrides from the database and clears any corresponding calendar events
*
* @param stdClass $quiz The quiz object.
* @param bool $log Whether to trigger logs.
*/
function quiz_delete_all_overrides($quiz, $log = true) {
global $DB;
$overrides = $DB->get_records('quiz_overrides', ['quiz' => $quiz->id], 'id');
foreach ($overrides as $override) {
quiz_delete_override($quiz, $override->id, $log);
}
}
/**
* Updates a quiz object with override information for a user.
*
@ -1620,7 +1545,7 @@ function quiz_reset_userdata($data) {
}
if ($purgeoverrides) {
cache::make('mod_quiz', 'overrides')->purge();
\cache_helper::purge_by_event(\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET);
}
return $status;
@ -2158,8 +2083,8 @@ function quiz_get_coursemodule_info($coursemodule) {
function mod_quiz_cm_info_dynamic(cm_info $cm) {
global $USER;
$cache = cache::make('mod_quiz', 'overrides');
$override = $cache->get("{$cm->instance}_u_{$USER->id}");
$cache = new override_cache($cm->instance);
$override = $cache->get_cached_user_override($USER->id);
if (!$override) {
$override = (object) [
@ -2174,7 +2099,7 @@ function mod_quiz_cm_info_dynamic(cm_info $cm) {
$closes = [];
$groupings = groups_get_user_groups($cm->course, $USER->id);
foreach ($groupings[0] as $groupid) {
$groupoverride = $cache->get("{$cm->instance}_g_{$groupid}");
$groupoverride = $cache->get_cached_group_override($groupid);
if (isset($groupoverride->timeopen)) {
$opens[] = $groupoverride->timeopen;
}

View File

@ -39,6 +39,7 @@ use core_question\local\bank\condition;
use mod_quiz\access_manager;
use mod_quiz\event\attempt_submitted;
use mod_quiz\grade_calculator;
use mod_quiz\local\override_manager;
use mod_quiz\question\bank\qbank_helper;
use mod_quiz\question\display_options;
use mod_quiz\quiz_attempt;
@ -1585,28 +1586,11 @@ function quiz_send_notify_manual_graded_message(quiz_attempt $attemptobj, object
* @return void
*/
function quiz_process_group_deleted_in_course($courseid) {
global $DB;
$affectedquizzes = override_manager::delete_orphaned_group_overrides_in_course($courseid);
// 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);
if (!$records) {
return; // Nothing to do.
if (!empty($affectedquizzes)) {
quiz_update_open_attempts(['quizid' => $affectedquizzes]);
}
$DB->delete_records_list('quiz_overrides', 'id', array_keys($records));
$cache = cache::make('mod_quiz', 'overrides');
foreach ($records as $record) {
$cache->delete("{$record->quiz}_g_{$record->groupid}");
}
quiz_update_open_attempts(['quizid' => array_unique(array_column($records, 'quiz'))]);
}
/**

View File

@ -38,11 +38,12 @@ $quiz = $quizobj->get_quiz();
$cm = $quizobj->get_cm();
$course = $quizobj->get_course();
$context = $quizobj->get_context();
$manager = $quizobj->get_override_manager();
require_login($course, false, $cm);
// Check the user has the required capabilities to modify an override.
require_capability('mod/quiz:manageoverrides', $context);
$manager->require_manage_capability();
if ($override->groupid) {
if (!groups_group_visible($override->groupid, $course, $cm)) {
@ -65,11 +66,7 @@ if (!empty($override->userid)) {
// If confirm is set (PARAM_BOOL) then we have confirmation of intention to delete.
if ($confirm) {
require_sesskey();
// Set the course module id before calling quiz_delete_override().
$quiz->cmid = $cm->id;
quiz_delete_override($quiz, $override->id);
$manager->delete_overrides(overrides: [$override]);
redirect($cancelurl);
}

View File

@ -46,6 +46,7 @@ $quiz = $quizobj->get_quiz();
$cm = $quizobj->get_cm();
$course = $quizobj->get_course();
$context = $quizobj->get_context();
$manager = $quizobj->get_override_manager();
$url = new moodle_url('/mod/quiz/overrideedit.php');
if ($action) {
@ -65,7 +66,7 @@ $PAGE->set_secondary_active_tab("mod_quiz_useroverrides");
require_login($course, false, $cm);
// Add or edit an override.
require_capability('mod/quiz:manageoverrides', $context);
$manager->require_manage_capability();
if ($overrideid) {
// Editing an override.
@ -121,98 +122,13 @@ if ($mform->is_cancelled()) {
redirect($url);
} else if ($fromform = $mform->get_data()) {
// Only include id when editing (i.e. action is empty).
if (empty($action) && !empty($overrideid)) {
$fromform->id = $overrideid;
}
// Process the data.
$fromform->quiz = $quiz->id;
// Replace unchanged values with null.
foreach ($keys as $key) {
if ($fromform->{$key} == $quiz->{$key}) {
$fromform->{$key} = null;
}
}
// See if we are replacing an existing override.
$userorgroupchanged = false;
if (empty($override->id)) {
$userorgroupchanged = true;
} else if (!empty($fromform->userid)) {
$userorgroupchanged = $fromform->userid !== $override->userid;
} else {
$userorgroupchanged = $fromform->groupid !== $override->groupid;
}
if ($userorgroupchanged) {
$conditions = [
'quiz' => $quiz->id,
'userid' => empty($fromform->userid) ? null : $fromform->userid,
'groupid' => empty($fromform->groupid) ? null : $fromform->groupid];
if ($oldoverride = $DB->get_record('quiz_overrides', $conditions)) {
// There is an old override, so we merge any new settings on top of
// the older override.
foreach ($keys as $key) {
if (is_null($fromform->{$key})) {
$fromform->{$key} = $oldoverride->{$key};
}
}
// Set the course module id before calling quiz_delete_override().
$quiz->cmid = $cm->id;
quiz_delete_override($quiz, $oldoverride->id);
}
}
// Set the common parameters for one of the events we may be triggering.
$params = [
'context' => $context,
'other' => [
'quizid' => $quiz->id
]
];
if (!empty($override->id)) {
$fromform->id = $override->id;
$DB->update_record('quiz_overrides', $fromform);
$cachekey = $groupmode ? "{$fromform->quiz}_g_{$fromform->groupid}" : "{$fromform->quiz}_u_{$fromform->userid}";
cache::make('mod_quiz', 'overrides')->delete($cachekey);
// Determine which override updated event to fire.
$params['objectid'] = $override->id;
if (!$groupmode) {
$params['relateduserid'] = $fromform->userid;
$event = \mod_quiz\event\user_override_updated::create($params);
} else {
$params['other']['groupid'] = $fromform->groupid;
$event = \mod_quiz\event\group_override_updated::create($params);
}
// Trigger the override updated event.
$event->trigger();
} else {
unset($fromform->id);
$fromform->id = $DB->insert_record('quiz_overrides', $fromform);
$cachekey = $groupmode ? "{$fromform->quiz}_g_{$fromform->groupid}" : "{$fromform->quiz}_u_{$fromform->userid}";
cache::make('mod_quiz', 'overrides')->delete($cachekey);
// Determine which override created event to fire.
$params['objectid'] = $fromform->id;
if (!$groupmode) {
$params['relateduserid'] = $fromform->userid;
$event = \mod_quiz\event\user_override_created::create($params);
} else {
$params['other']['groupid'] = $fromform->groupid;
$event = \mod_quiz\event\group_override_created::create($params);
}
// Trigger the override created event.
$event->trigger();
}
quiz_update_open_attempts(['quizid' => $quiz->id]);
if ($groupmode) {
// Priorities may have shifted, so we need to update all of the calendar events for group overrides.
quiz_update_events($quiz);
} else {
// User override. We only need to update the calendar event for this user override.
quiz_update_events($quiz, $fromform);
}
$id = $manager->save_override((array) $fromform);
if (!empty($fromform->submitbutton)) {
redirect($overridelisturl);
@ -221,7 +137,7 @@ if ($mform->is_cancelled()) {
// The user pressed the 'again' button, so redirect back to this page.
$url->remove_params('cmid');
$url->param('action', 'duplicate');
$url->param('id', $fromform->id);
$url->param('id', $id);
redirect($url);
}

View File

@ -630,6 +630,7 @@ class events_test extends \advanced_testcase {
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$quizsettings = quiz_settings::create($quiz->id);
// Create an override.
$override = new \stdClass();
@ -639,7 +640,7 @@ class events_test extends \advanced_testcase {
// Trigger and capture the event.
$sink = $this->redirectEvents();
quiz_delete_override($quiz, $override->id);
$quizsettings->get_override_manager()->delete_overrides(overrides: [$override]);
$events = $sink->get_events();
$event = reset($events);
@ -660,6 +661,7 @@ class events_test extends \advanced_testcase {
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$quizsettings = quiz_settings::create($quiz->id);
// Create an override.
$override = new \stdClass();
@ -669,7 +671,7 @@ class events_test extends \advanced_testcase {
// Trigger and capture the event.
$sink = $this->redirectEvents();
quiz_delete_override($quiz, $override->id);
$quizsettings->get_override_manager()->delete_overrides(overrides: [$override]);
$events = $sink->get_events();
$event = reset($events);

View File

@ -0,0 +1,86 @@
<?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;
use mod_quiz\local\override_cache;
/**
* Cache manager tests 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
* @covers \mod_quiz\local\override_cache
*/
final class override_cache_test extends \advanced_testcase {
/**
* Tests CRUD functions of the override_cache
*/
public function test_crud(): void {
// Cache is normally protected, but for testing we reflect it and put test data into it.
$overridecache = new override_cache(0);
$reflection = new \ReflectionClass($overridecache);
$getcache = $reflection->getMethod('get_cache');
$getcache->setAccessible(true);
$cache = $getcache->invoke($overridecache);
$getuserkey = $reflection->getMethod('get_user_cache_key');
$getuserkey->setAccessible(true);
$getgroupkey = $reflection->getMethod('get_group_cache_key');
$getgroupkey->setAccessible(true);
$dummydata = (object)[
'userid' => 1234,
];
// Set some data.
$cache->set($getuserkey->invoke($overridecache, 123), $dummydata);
$cache->set($getgroupkey->invoke($overridecache, 456), $dummydata);
// Get the data back.
$this->assertEquals($dummydata, $overridecache->get_cached_user_override(123));
$this->assertEquals($dummydata, $overridecache->get_cached_group_override(456));
// Delete.
$overridecache->clear_for_user(123);
$overridecache->clear_for_group(456);
$this->assertEmpty($overridecache->get_cached_user_override(123));
$this->assertEmpty($overridecache->get_cached_group_override(456));
// Put some data back.
$cache->set($getuserkey->invoke($overridecache, 123), $dummydata);
$cache->set($getgroupkey->invoke($overridecache, 456), $dummydata);
// Clear it.
$overridecache->clear_for(123, 456);
$this->assertEmpty($overridecache->get_cached_user_override(123));
$this->assertEmpty($overridecache->get_cached_group_override(456));
// Put some data back.
$cache->set($getuserkey->invoke($overridecache, 123), 'testuser');
$cache->set($getgroupkey->invoke($overridecache, 456), 'testgroup');
// Purge it.
\cache_helper::purge_by_event(\mod_quiz\local\override_cache::INVALIDATION_USERDATARESET);
$this->assertEmpty($overridecache->get_cached_user_override(123));
$this->assertEmpty($overridecache->get_cached_group_override(456));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -535,7 +535,7 @@ class provider_test extends \core_privacy\tests\provider_testcase {
// Delete the data for user1 and user3 in course1 and check it is removed.
$quiz1context = $quiz1obj->get_context();
$approveduserlist = new \core_privacy\local\request\approved_userlist($quiz1context, 'mod_quiz',
[$user1->id, $user3->id]);
[$user1->id, $user3->id]);
provider::delete_data_for_users($approveduserlist);
// Only the attempt of user2 should be remained in quiz1.

View File

@ -22,6 +22,12 @@ This file describes API changes in the quiz code.
* The following previously deprecated methods have been removed and can no longer be used:
- `get_slot_tags_for_slot_id`
- `quiz_retrieve_tags_for_slot_ids`
* Quiz override logic has been refactored and put in a new override_manager class.
* Quiz override cache should now be accessed using the new override_cache class.
* The quiz_delete_overrides and quiz_delete_all_overrides functions are now deprecated. Please instead use:
- override_manager::delete_override_by_id
- override_manager::delete_overrides
- override_manager::delete_all_overrides
=== 4.3 ===

View File

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2023112300;
$plugin->version = 2023112303;
$plugin->requires = 2023100400;
$plugin->component = 'mod_quiz';