From 2b972501931e15ecaf56a4d3169b8cdb0d3bdeeb Mon Sep 17 00:00:00 2001 From: David Szkiba Date: Mon, 30 Sep 2024 18:07:49 +0200 Subject: [PATCH 1/2] GH-620 Close open attempts --- classes/local/status.php | 8 + classes/task/cancel_expired_attempts.php | 188 ++++++++++++++++++++ db/tasks.php | 10 ++ lang/de/local_catquiz.php | 4 + lang/en/local_catquiz.php | 4 + settings.php | 9 + tests/task/cancel_expired_attempts_test.php | 114 ++++++++++++ version.php | 2 +- 8 files changed, 338 insertions(+), 1 deletion(-) create mode 100755 classes/task/cancel_expired_attempts.php create mode 100644 tests/task/cancel_expired_attempts_test.php diff --git a/classes/local/status.php b/classes/local/status.php index 77159624b..e1200dc84 100644 --- a/classes/local/status.php +++ b/classes/local/status.php @@ -101,6 +101,13 @@ class status { */ const EXCEEDED_MAX_ATTEMPT_TIME = 'exceededmaxattempttime'; + /** + * Indicates that the attempt was closed automatically. + * + * @var string + */ + const CLOSED_BY_TIMELIMIT = 'attemptclosedbytimelimit'; + /** * An undefined status * @@ -124,6 +131,7 @@ class status { self::ERROR_EMPTY_FIRST_QUESTION_LIST => 6, self::ERROR_NO_ITEMS => 7, self::EXCEEDED_MAX_ATTEMPT_TIME => 8, + self::CLOSED_BY_TIMELIMIT => 9, ]; /** diff --git a/classes/task/cancel_expired_attempts.php b/classes/task/cancel_expired_attempts.php new file mode 100755 index 000000000..a1b0db9fa --- /dev/null +++ b/classes/task/cancel_expired_attempts.php @@ -0,0 +1,188 @@ +. + +/** + * Class cancel_expired_attempts. + * + * @package local_catquiz + * @author David Szkiba + * @copyright 2024 Wunderbyte GmbH + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace local_catquiz\task; + +use cache_helper; +use context_module; +use dml_exception; +use local_catquiz\catquiz; +use local_catquiz\local\status; +use mod_adaptivequiz\local\attempt\attempt; +use mod_adaptivequiz\local\attempt\attempt_state; +use stdClass; + +global $CFG; +require_once("$CFG->dirroot/mod/adaptivequiz/locallib.php"); + +/** + * Cancels open CAT quiz attempts that exceeded the timeout. + * + * @package local_catquiz + * @author David Szkiba + * @copyright 2024 Wunderbyte GmbH + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cancel_expired_attempts extends \core\task\scheduled_task { + + /** + * Holds adaptivequiz records as stdClass entires. + * + * @var array + */ + private array $quizzes = []; + + /** + * Holds the local_catquiz_tests of open attempts. + * + * @var array + */ + private array $maxtimepertest = []; + /** + * Returns task name. + * @return string + */ + public function get_name() { + return get_string('cancelexpiredattempts', 'local_catquiz'); + } + + /** + * Cancel expired quiz attempts. + * + * @return void + */ + public function execute() { + global $DB; + mtrace("Running cancel_expired_attempts task."); + + // Get all catquiz attempts that are still in progress. + $sql = << attempt_state::IN_PROGRESS]; + if (!$records = $DB->get_records_sql($sql, $params)) { + mtrace("No attempts are in progress. Exiting."); + return; + } + + // Get all local_catquiz_tests records that are used by open attempts. + $openinstances = array_unique( + array_map( + fn($r) => $r->instance, + $records + ) + ); + // For each test, get the maximum time per attempt setting. + foreach ($DB->get_records_list('local_catquiz_tests', 'componentid', $openinstances) as $tr) { + $settings = json_decode($tr->json); + // If this setting is not given, it is not limited on the quiz level. + if ( + !property_exists($settings, 'catquiz_timelimitgroup') + || !$settings->catquiz_timelimitgroup + ) { + $this->maxtimepertest[$tr->componentid] = null; + } + + // The max time per attempt can be given in minutes or hours. We convert it to seconds to + // compare it to the current time. + $maxtimeperattempt = $settings->catquiz_timelimitgroup->catquiz_maxtimeperattempt * 60; + if ($settings->catquiz_timelimitgroup->catquiz_timeselect_attempt == 'h') { + $maxtimeperattempt *= 60; + } + $this->maxtimepertest[$tr->componentid] = $maxtimeperattempt; + } + + // For each record, check if the attempt is running longer than the default maximum time or the + // maximum time defined by the quiz. If so, mark it as completed with the exceeded threshold state. + $now = time(); + $defaultmaxtime = 60 * 60 * get_config('local_catquiz', 'maximum_attempt_duration_hours'); + $completed = 0; + $statusmessage = get_string('attemptclosedbytimelimit', 'local_catquiz'); + foreach ($records as $record) { + // If it is set on a quiz setting basis and not triggered, ignore the default setting. + $quizmaxtime = $this->maxtimepertest[$record->instance]; + $exceedsmaxtime = $this->exceeds_maxtime($record->timecreated, $quizmaxtime, $defaultmaxtime, $now); + if ($exceedsmaxtime) { + $attempt = attempt::get_by_id($record->id); + $quiz = $this->get_adaptivequiz($record->instance); + $cm = get_coursemodule_from_instance('adaptivequiz', $record->instance); + $context = context_module::instance($cm->id); + $attempt->complete($quiz, $context, $statusmessage, $now); + catquiz::set_final_attempt_status($record->id, status::CLOSED_BY_TIMELIMIT); + cache_helper::purge_by_event('changesinquizattempts'); + $completed++; + } + } + $duration = time() - $now; + mtrace(sprintf( + 'Processed %d open attempts in %d seconds and marked %d as completed', + count($records), + $duration, + $completed + )); + } + + /** + * Checks whether the given attempt exceeds the max attempt time + * + * @param int $timecreated Timestamp when the attempt was created + * @param ?int $quizmaxtime Maximum time specified by the quiz + * @param int $defaultmaxtime Default maximum time + * @return bool + */ + public function exceeds_maxtime(int $timecreated, ?int $quizmaxtime, int $defaultmaxtime, int $now): bool { + // Get the timeout that should be used. + // If a timeout is set per quiz, use this. If not, fall back to the global default. + $maxtime = $quizmaxtime ?? $defaultmaxtime; + // The value 0 is treated as "no limit". + if ($maxtime === 0) { + return false; + } + return $now - $timecreated > $maxtime; + } + + /** + * Returns an adaptivequiz with the given ID. + * + * @param int $id + * @return stdClass + * @throws dml_exception + */ + private function get_adaptivequiz(int $id): stdClass { + global $DB; + if (array_key_exists($id, $this->quizzes)) { + return $this->quizzes[$id]; + } + + $adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $id], '*', MUST_EXIST); + $this->quizzes[$adaptivequiz->id] = $adaptivequiz; + return $adaptivequiz; + } +} diff --git a/db/tasks.php b/db/tasks.php index 765b3e50f..9cb5498ac 100755 --- a/db/tasks.php +++ b/db/tasks.php @@ -22,6 +22,7 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use local_catquiz\task\cancel_expired_attempts; use local_catquiz\task\recalculate_cat_model_params; defined('MOODLE_INTERNAL') || die(); @@ -36,4 +37,13 @@ 'dayofweek' => '*', 'month' => '*', ], + [ + 'classname' => cancel_expired_attempts::class, + 'blocking' => 0, + 'minute' => '*/5', // Runs every 5 minutes. + 'hour' => '*', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*', + ], ]; diff --git a/lang/de/local_catquiz.php b/lang/de/local_catquiz.php index 971e5c420..0c135770c 100644 --- a/lang/de/local_catquiz.php +++ b/lang/de/local_catquiz.php @@ -56,6 +56,7 @@ $string['assigntestitemstocatscales'] = 'Weise den Skalen Fragen zu'; $string['attempt_completed'] = 'Testversuch abgeschlossen'; $string['attemptchartstitle'] = 'Anzahl und Ergebnisse der Testversuche für Skala „{$a}“'; +$string['attemptclosedbytimelimit'] = 'Versuch wurde wegen Zeitüberschreitung automatisch beendet'; $string['attemptfeedbacknotavailable'] = 'Kein Feedback verfügbar'; $string['attemptfeedbacknotyetavailable'] = 'Das Feedback wird angezeigt, sobald der laufende Versuch beendet ist.'; $string['attempts'] = 'Testversuche'; @@ -95,6 +96,7 @@ $string['callbackfunctionnotapplied'] = 'Callback Funktion konnte nicht angewandt werden.'; $string['callbackfunctionnotdefined'] = 'Callback Funktion nicht definiert.'; $string['canbesetto0iflabelgiven'] = 'Kann 0 sein, wenn Abgleich über Label stattfindet.'; +$string['cancelexpiredattempts'] = 'Abgelaufene Versuche schließen'; $string['cannotdeletescalewithchildren'] = 'Skalen mit Unterskalen können nicht gelöscht werden.'; $string['catcatscaleprime'] = 'Inhaltsbereich (Globalskala)'; $string['catcatscaleprime_help'] = 'Wählen Sie den für Sie relevanten Inhaltsbereich aus. Inhaltsbereche werden als Skala durch eine*n CAT-Manager*in angelegt und verwaltet. Falls Sie eigene Inhalts- und Unterbereiche wünschen, wenden Sie sich bitte an den oder die CAT-Manager*in oder den bzw. die Adminstrator*in Ihrer Moodle-Instanz.'; @@ -439,6 +441,8 @@ $string['max_iterations'] = 'Maximale Anzahl an Iterationen'; $string['maxabilityscalevalue'] = 'Maximale Personenfähigkeit:'; $string['maxabilityscalevalue_help'] = 'Geben Sie die größtmögliche Personenfähigkeit dieser Skala als Dezimalwert an. Der Mittelwert ist null.'; +$string['maxattemptduration'] = 'Maximale Laufzeit für Versuche'; +$string['maxattemptduration_desc'] = 'Versuche die älter sind werden automatisch geschlossen. Ein Wert von 0 bedeutet, dass die Laufzeit unbeschränkt ist. Dieser Wert kann in den Quiz-Einstellungen überschrieben werden.'; $string['maxquestionspersubscale'] = 'max. Frageanzahl pro Skala'; $string['maxquestionspersubscale_help'] = 'Wenn von einer Skala so viele Fragen angezeigt wurden, werden keine weiteren Fragen dieser Skala mehr ausgespielt. Wenn auf 0 gesetzt, dann gibt es kein Limit.'; $string['maxscalevalue'] = 'Maximalwert'; diff --git a/lang/en/local_catquiz.php b/lang/en/local_catquiz.php index fa9d23a1f..241e40293 100644 --- a/lang/en/local_catquiz.php +++ b/lang/en/local_catquiz.php @@ -56,6 +56,7 @@ $string['assigntestitemstocatscales'] = 'Assign testitem to CAT scale'; $string['attempt_completed'] = 'Attempt completed'; $string['attemptchartstitle'] = 'Number and results of attempts in scale “{$a}”'; +$string['attemptclosedbytimelimit'] = 'Attempt automatically closed due to exceeded time limit'; $string['attemptfeedbacknotavailable'] = 'No feedback available'; $string['attemptfeedbacknotyetavailable'] = 'Feedback for attempts will be displayed when available.'; $string['attempts'] = 'Attempts'; @@ -95,6 +96,7 @@ $string['callbackfunctionnotapplied'] = 'Callback function could not be applied.'; $string['callbackfunctionnotdefined'] = 'Callback function is not defined.'; $string['canbesetto0iflabelgiven'] = 'Can be 0 if matching of testitem is via label.'; +$string['cancelexpiredattempts'] = 'Cancel attempts exceeding maximum time'; $string['cannotdeletescalewithchildren'] = 'Cannot delete CAT scale with children'; $string['catcatscaleprime'] = 'Content/Scale'; $string['catcatscaleprime_help'] = 'Select the content area that is relevant to you. Content areas are created and managed as a so-called scale by a CAT manager. If you would like your own content and sub-areas, please contact the CAT manager or the administrator of your Moodle instance.'; @@ -420,6 +422,8 @@ $string['max_iterations'] = 'Maximum number of iterations'; $string['maxabilityscalevalue'] = 'Person ability maximum:'; $string['maxabilityscalevalue_help'] = 'Enter the highest possible person ability of this scale as a positive decimal value. The mean is zero.'; +$string['maxattemptduration'] = 'The maximum duration of attempts'; +$string['maxattemptduration_desc'] = 'Attempts older than this will be marked as completed. A value of 0 means that there is no limit. This value can be overwritten by the quiz settings.'; $string['maxquestionspersubscale'] = 'Maximum number of questions returned per subscale'; $string['maxquestionspersubscale_help'] = 'When this number of questions was returned for any subscale, no more questions from this scale will be shown. A value of 0 means that there is no limit.'; $string['maxscalevalue'] = 'Max value'; diff --git a/settings.php b/settings.php index 3afc71865..7b5e48567 100644 --- a/settings.php +++ b/settings.php @@ -118,4 +118,13 @@ get_string('store_debug_info_name', 'local_catquiz'), get_string('store_debug_info_desc', 'local_catquiz'), 0)); + + // Add a setting for the default maximum attempt duration. + $settings->add(new admin_setting_configtext( + 'local_catquiz/maximum_attempt_duration_hours', + get_string('maxattemptduration', 'local_catquiz'), + get_string('maxattemptduration_desc', 'local_catquiz'), + 24, // Default value + PARAM_INT // Expect integer type + )); } diff --git a/tests/task/cancel_expired_attempts_test.php b/tests/task/cancel_expired_attempts_test.php new file mode 100644 index 000000000..183496ff9 --- /dev/null +++ b/tests/task/cancel_expired_attempts_test.php @@ -0,0 +1,114 @@ +. + +/** + * Tests the cancel_expired_tests functionality. + * + * @package local_catquiz + * @author David Szkiba + * @copyright 2023 Georg Maißer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @autor David Szkiba + * @copirignt 2023 Georg Maißer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace local_catquiz; + +use basic_testcase; +use local_catquiz\task\cancel_expired_attempts; + +/** + * Tests the mathcat functionality. + * + * @package local_catquiz + * @author David Szkiba + * @copyright 2023 Georg Maißer + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \local_catquiz\mathcat + * + */ +final class cancel_expired_attempts_test extends basic_testcase { + + /** + * Test if the newton raphson multi stable function returns expected outputs. + * + * @dataProvider attempt_expiration_is_detected_correctly_provider + * @return void + */ + public function test_attempt_expiration_is_detected_correctly(int $attemptcreated, ?int $quizmaxtime, int $defaultmaxtime, int $now, bool $expected) { + $task = new cancel_expired_attempts(); + $shouldbecompleted = $task->exceeds_maxtime($attemptcreated, $quizmaxtime, $defaultmaxtime, $now); + $this->assertEquals($expected, $shouldbecompleted); + } + + /** + * Data provider for test cases that check if attempt expiration is detected correctly. + * + * Generates sample data sets for testing various scenarios of attempt expiration. + * + * @return array An array of test data sets. + */ + public static function attempt_expiration_is_detected_correctly_provider(): array { + $now = time(); + $starttime = $now - 5 * 60; // Started 5 minutes ago. + return [ + 'quiz setting and expired' => [ + $starttime, + 60, // Allow just 1 minute. + 3600, // This should be ignored. + $now, + true, + ], + 'quiz setting and not expired' => [ + $starttime, + 10 * 60, // Allow 10 minutes. + 1, // This should be ignored. + $now, + false, + ], + 'quiz setting and not expired with 0 as no-limit' => [ + $starttime, + 0, // Unlimited. + 1, // This should be ignored. + $now, + false, + ], + 'no quiz setting and default and expired' => [ + $starttime, + null, + 60, // Allow just 1 minute. + $now, + true, + ], + 'no quiz setting and default and not expired' => [ + $starttime, + null, + 3600, // Allow 1 hour. + $now, + false, + ], + 'no quiz setting and default and not expired with 0 as no-limit' => [ + $starttime, + null, + 0, // Unlimited. + $now, + false, + ], + ]; + } +} diff --git a/version.php b/version.php index 5f7ae693b..09f640082 100644 --- a/version.php +++ b/version.php @@ -26,7 +26,7 @@ $plugin->component = 'local_catquiz'; $plugin->release = '1.1.0'; -$plugin->version = 2024092700; +$plugin->version = 2024093000; $plugin->requires = 2022041900; $plugin->maturity = MATURITY_STABLE; $plugin->dependencies = [ From 2eaf7b4d8fb53a056363ef32f0ce84d05e86cc6f Mon Sep 17 00:00:00 2001 From: David Szkiba Date: Tue, 1 Oct 2024 13:38:00 +0200 Subject: [PATCH 2/2] GH-620 linting fixes --- classes/task/cancel_expired_attempts.php | 3 +++ settings.php | 6 +++--- tests/task/cancel_expired_attempts_test.php | 18 +++++++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/classes/task/cancel_expired_attempts.php b/classes/task/cancel_expired_attempts.php index a1b0db9fa..9a2faf519 100755 --- a/classes/task/cancel_expired_attempts.php +++ b/classes/task/cancel_expired_attempts.php @@ -34,6 +34,8 @@ use mod_adaptivequiz\local\attempt\attempt_state; use stdClass; +defined('MOODLE_INTERNAL') || die(); + global $CFG; require_once("$CFG->dirroot/mod/adaptivequiz/locallib.php"); @@ -155,6 +157,7 @@ public function execute() { * @param int $timecreated Timestamp when the attempt was created * @param ?int $quizmaxtime Maximum time specified by the quiz * @param int $defaultmaxtime Default maximum time + * @param int $now * @return bool */ public function exceeds_maxtime(int $timecreated, ?int $quizmaxtime, int $defaultmaxtime, int $now): bool { diff --git a/settings.php b/settings.php index 7b5e48567..eaf281a8b 100644 --- a/settings.php +++ b/settings.php @@ -124,7 +124,7 @@ 'local_catquiz/maximum_attempt_duration_hours', get_string('maxattemptduration', 'local_catquiz'), get_string('maxattemptduration_desc', 'local_catquiz'), - 24, // Default value - PARAM_INT // Expect integer type - )); + 24, // Default value. + PARAM_INT // Expect integer type. + )); } diff --git a/tests/task/cancel_expired_attempts_test.php b/tests/task/cancel_expired_attempts_test.php index 183496ff9..93baa1368 100644 --- a/tests/task/cancel_expired_attempts_test.php +++ b/tests/task/cancel_expired_attempts_test.php @@ -21,9 +21,6 @@ * @author David Szkiba * @copyright 2023 Georg Maißer * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @autor David Szkiba - * @copirignt 2023 Georg Maißer - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace local_catquiz; @@ -47,10 +44,21 @@ final class cancel_expired_attempts_test extends basic_testcase { /** * Test if the newton raphson multi stable function returns expected outputs. * - * @dataProvider attempt_expiration_is_detected_correctly_provider + * @param int $attemptcreated + * @param ?int $quizmaxtime + * @param int $defaultmaxtime + * @param int $now + * @param bool $expected * @return void + * @dataProvider attempt_expiration_is_detected_correctly_provider */ - public function test_attempt_expiration_is_detected_correctly(int $attemptcreated, ?int $quizmaxtime, int $defaultmaxtime, int $now, bool $expected) { + public function test_attempt_expiration_is_detected_correctly( + int $attemptcreated, + ?int $quizmaxtime, + int $defaultmaxtime, + int $now, + bool $expected + ): void { $task = new cancel_expired_attempts(); $shouldbecompleted = $task->exceeds_maxtime($attemptcreated, $quizmaxtime, $defaultmaxtime, $now); $this->assertEquals($expected, $shouldbecompleted);