Rev 1 | AutorÃa | Comparar con el anterior | Ultima modificación | Ver Log |
<?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.groupidFROM {quiz_overrides} oJOIN {quiz} quiz ON quiz.id = o.quizLEFT JOIN {groups} grp ON grp.id = o.groupidWHERE quiz.course = :courseidAND o.groupid IS NOT NULLAND 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'));}}