From f8f7fad019d2c7b1a31d82b121dd0875758be2e4 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Tue, 21 Oct 2025 00:53:09 -0500 Subject: [PATCH] Exercise: Persist question order & resume position across sessions - refs BT#22999 --- public/main/exercise/exercise_submit.php | 619 ++++++++++++----------- 1 file changed, 325 insertions(+), 294 deletions(-) diff --git a/public/main/exercise/exercise_submit.php b/public/main/exercise/exercise_submit.php index e54d59350c5..788f7db69f1 100644 --- a/public/main/exercise/exercise_submit.php +++ b/public/main/exercise/exercise_submit.php @@ -467,6 +467,21 @@ } } Session::write('exe_id', $exe_id); + +// Always restore persisted question order from DB (data_tracking) on resume +// Rationale: session may be lost between visits; data_tracking is the single source of truth +// for the question order selected at the very first entry of the attempt. +if (!empty($exercise_stat_info) && !empty($exercise_stat_info['data_tracking'])) { + $restoredQuestionList = array_map('intval', explode(',', $exercise_stat_info['data_tracking'])); + // Keep original order but remove media questions that are not renderable + $restoredQuestionList = array_values(array_filter($restoredQuestionList, function (int $qid) { + $q = Question::read($qid); + return $q && $q->type !== MEDIA_QUESTION; + })); + // Store into session for navigation helpers; DB remains the canonical source. + Session::write('questionList', $restoredQuestionList); +} + $checkAnswersUrl = api_get_path(WEB_AJAX_PATH).'exercise.ajax.php?a=check_answers&exe_id='.$exe_id.'&'.api_get_cidreq(); $saveDurationUrl = api_get_path(WEB_AJAX_PATH).'exercise.ajax.php?a=update_duration&exe_id='.$exe_id.'&'.api_get_cidreq(); $questionListInSession = Session::read('questionList'); @@ -482,42 +497,43 @@ } if (!isset($questionListInSession)) { - // Selects the list of question ID - $questionList = $objExercise->getQuestionList(); + // === FIX: Prefer persisted selection (data_tracking) to guarantee stable order across sessions === + if (!empty($exercise_stat_info) && !empty($exercise_stat_info['data_tracking'])) { + $questionList = array_map('intval', explode(',', $exercise_stat_info['data_tracking'])); + } else { + // Brand-new attempt fallback: build from current exercise definition + $questionList = $objExercise->getQuestionList(); + } - $questionList = array_filter($questionList, function(int $qid) { + // Keep order; filter out non-renderable media questions + $questionList = array_values(array_filter($questionList, function (int $qid) { $q = Question::read($qid); return $q && $q->type !== MEDIA_QUESTION; - }); + })); - // Getting order from random - if ( - ( - $objExercise->isRandom() || - !empty($objExercise->getRandomByCategory()) || - $selectionType > EX_Q_SELECTION_RANDOM - ) && - isset($exercise_stat_info) && - !empty($exercise_stat_info['data_tracking']) - ) { - $questionList = explode(',', $exercise_stat_info['data_tracking']); - $questionList = array_filter($questionList, function(int $qid) { - $q = Question::read($qid); - return $q && $q->type !== MEDIA_QUESTION; - }); + // Prepare category grouping when feature is enabled, using the persisted list order. + if ($allowBlockCategory) { $categoryList = []; - if ($allowBlockCategory) { - foreach ($questionList as $question) { - $categoryId = TestCategory::getCategoryForQuestion($question); - $categoryList[$categoryId][] = $question; - } - Session::write('categoryList', $categoryList); + foreach ($questionList as $question) { + $categoryId = TestCategory::getCategoryForQuestion($question); + $categoryList[$categoryId][] = $question; } + Session::write('categoryList', $categoryList); } + Session::write('questionList', $questionList); } else { if (isset($objExercise) && isset($exerciseInSession)) { $questionList = Session::read('questionList'); + // Ensure categoryList exists if blocking by category is enabled + if ($allowBlockCategory && Session::read('categoryList') === null) { + $categoryList = []; + foreach ($questionList as $question) { + $categoryId = TestCategory::getCategoryForQuestion($question); + $categoryList[$categoryId][] = $question; + } + Session::write('categoryList', $categoryList); + } } } // Array to check in order to block the chat @@ -644,12 +660,19 @@ } //in LP's is enabled the "remember question" feature? +// Do not rely on random flags; always prefer persisted data_tracking when available if (!isset($_SESSION['questionList'])) { - // selects the list of question ID - $questionList = $objExercise->get_validated_question_list(); - if ($objExercise->isRandom() && !empty($exercise_stat_info['data_tracking'])) { - $questionList = explode(',', $exercise_stat_info['data_tracking']); + if (!empty($exercise_stat_info['data_tracking'])) { + $questionList = array_map('intval', explode(',', $exercise_stat_info['data_tracking'])); + } else { + // Fallback for a brand-new attempt + $questionList = $objExercise->get_validated_question_list(); } + // Keep order; filter out non-renderable media questions + $questionList = array_values(array_filter($questionList, function (int $qid) { + $q = Question::read($qid); + return $q && $q->type !== MEDIA_QUESTION; + })); Session::write('questionList', $questionList); } else { if (isset($objExercise) && isset($_SESSION['objExercise'])) { @@ -1002,8 +1025,16 @@ $current_question = 1; $latestQuestionId = Event::getLatestQuestionIdFromAttempt($exe_id); if ($latestQuestionId) { - $pos = (int) $objExercise->getPositionInCompressedQuestionList($latestQuestionId); - $current_question = max(1, $pos + 1); + // Resume position using the persisted questionList order + // Using DB-backed order avoids inconsistencies when sessions expire. + $idx = array_search((int) $latestQuestionId, $questionList ?? [], true); + if ($idx === false) { + // Fallback to legacy computation if not found in current in-memory list + $pos = (int) $objExercise->getPositionInCompressedQuestionList($latestQuestionId); + $current_question = max(1, $pos + 1); + } else { + $current_question = $idx + 1; + } } } @@ -1364,42 +1395,42 @@ $script_php = 'exercise_reminder.php'; } - if (!empty($exercise_sound)) { - echo ''; - echo ', get_lang('; - } - // Get number of hotspot questions for javascript validation - $number_of_hotspot_questions = 0; - $i = 0; - if (!empty($questionList)) { - foreach ($questionList as $questionId) { - $i++; - $objQuestionTmp = Question::read($questionId); - $selectType = $objQuestionTmp->selectType(); - // for sequential exercises - - if (ONE_PER_PAGE == $objExercise->type) { - // if it is not the right question, goes to the next loop iteration - if ($current_question != $i) { - continue; - } else { - if (in_array($selectType, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_DELINEATION])) { - $number_of_hotspot_questions++; - } +} +// Get number of hotspot questions for javascript validation +$number_of_hotspot_questions = 0; +$i = 0; +if (!empty($questionList)) { + foreach ($questionList as $questionId) { + $i++; + $objQuestionTmp = Question::read($questionId); + $selectType = $objQuestionTmp->selectType(); + // for sequential exercises - break; - } + if (ONE_PER_PAGE == $objExercise->type) { + // if it is not the right question, goes to the next loop iteration + if ($current_question != $i) { + continue; } else { if (in_array($selectType, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_DELINEATION])) { $number_of_hotspot_questions++; } + + break; + } + } else { + if (in_array($selectType, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_DELINEATION])) { + $number_of_hotspot_questions++; } } } +} if ($allowBlockCategory && ONE_PER_PAGE == $objExercise->type && @@ -1418,13 +1449,13 @@ } } } - $saveIcon = Display::getMdiIcon( - ActionIcon::SAVE_FORM, - 'ch-tool-icon', - null, - ICON_SIZE_SMALL - ); - $loading = Display::getMdiIcon('loading', 'animate-spin'); +$saveIcon = Display::getMdiIcon( + ActionIcon::SAVE_FORM, + 'ch-tool-icon', + null, + ICON_SIZE_SMALL +); +$loading = Display::getMdiIcon('loading', 'animate-spin'); echo '