diff --git a/assets/src/js/admin-dashboard/segments/options.js b/assets/src/js/admin-dashboard/segments/options.js index 0e0c329586..b998aa24f1 100644 --- a/assets/src/js/admin-dashboard/segments/options.js +++ b/assets/src/js/admin-dashboard/segments/options.js @@ -715,104 +715,157 @@ document.addEventListener('DOMContentLoaded', function () { } /** - * Toggle turn-off confirmation modals. + * Option change and turn-off confirmation modals. * - * Intercepts the change event on tutor-form-toggle-input elements that are - * configured in the localized `tutorTurnoffConfirm` map (keyed by field key). - * When such a toggle is turned OFF, the handler reverts the toggle, shows a - * confirm modal, and only proceeds with the turn-off if the user confirms. + * Intercepts user interactions on options configured in the localized + * `tutorOptionConfirmations` map (keyed by field key). * - * The config can include a usage-check AJAX action to decide whether the - * modal needs to be shown at all. + * Supports: + * - 'turnoff': When a toggle switch is turned OFF. + * - 'change': When a field value (e.g. select dropdown) changes. * * The map is localized by Tutor Pro; Free only provides this generic, * configuration-driven mechanism. * * @since 4.1.0 */ - const turnOffConfirmations = window.tutorTurnoffConfirm || {}; - Object.entries(turnOffConfirmations).forEach(([fieldKey, config]) => { - document.querySelectorAll(`#field_${fieldKey} .tutor-form-toggle-input`).forEach((checkbox) => { - checkbox.addEventListener('change', function (e) { - if (this.checked) { - return; - } - - const message = config.message; - const title = config.title; - const cancelText = config.cancel; - const confirmText = config.confirm; - const usageAjaxAction = config.usage_check_action; - if (!message) { - return; - } + const optionConfirmations = window.tutorOptionConfirmations || {}; + Object.entries(optionConfirmations).forEach(([fieldKey, config]) => { + const message = config.message; + const title = config.title; + const cancelText = config.cancel; + const confirmText = config.confirm; + const usageAjaxAction = config.usage_check_action; + const confirmationType = config.type || 'turnoff'; + + if (!message) { + return; + } - const hiddenInput = this.previousElementSibling; - const syncToggleVisibility = () => { - const $toggle = $(this); - if ($toggle.data('toggle-fields')) { - showHideToggleChildren($toggle); - } - if ($toggle.data('toggle-blocks')) { - showHideToggleBlock($toggle); + if (confirmationType === 'turnoff') { + document.querySelectorAll(`#field_${fieldKey} .tutor-form-toggle-input`).forEach((checkbox) => { + checkbox.addEventListener('change', function (e) { + if (this.checked) { + return; } - }; - const revertToggle = () => { - this.checked = true; - if (hiddenInput) { - hiddenInput.value = 'on'; - } - syncToggleVisibility(); - }; + const hiddenInput = this.previousElementSibling; + const syncToggleVisibility = () => { + const $toggle = $(this); + if ($toggle.data('toggle-fields')) { + showHideToggleChildren($toggle); + } + if ($toggle.data('toggle-blocks')) { + showHideToggleBlock($toggle); + } + }; - const proceedWithTurnoff = () => { - this.checked = false; - if (hiddenInput) { - hiddenInput.value = 'off'; - } - syncToggleVisibility(); - }; + const revertToggle = () => { + this.checked = true; + if (hiddenInput) { + hiddenInput.value = 'on'; + } + syncToggleVisibility(); + }; - if (!usageAjaxAction) { - revertToggle(); - tutorConfirmTurnoffModal(message, title, cancelText, confirmText).then((confirmed) => { - if (confirmed) { - proceedWithTurnoff(); + const proceedWithTurnoff = () => { + this.checked = false; + if (hiddenInput) { + hiddenInput.value = 'off'; } - }); - return; - } + syncToggleVisibility(); + }; + + const confirmAndTurnoff = () => { + revertToggle(); + tutorConfirmOptionModal(message, title, cancelText, confirmText).then((confirmed) => { + if (confirmed) { + proceedWithTurnoff(); + } + }); + }; - const formData = new FormData(); - formData.append('action', usageAjaxAction); - formData.append(_tutorobject.nonce_key, _tutorobject._tutor_nonce); + if (!usageAjaxAction) { + confirmAndTurnoff(); + return; + } - fetch(_tutorobject.ajaxurl, { method: 'POST', body: formData }) - .then((response) => response.json()) - .then((result) => { - const hasCustomized = result?.data?.has_customized; - if (hasCustomized) { + const formData = new FormData(); + formData.append('action', usageAjaxAction); + formData.append(_tutorobject.nonce_key, _tutorobject._tutor_nonce); + + fetch(_tutorobject.ajaxurl, { method: 'POST', body: formData }) + .then((response) => response.json()) + .then((result) => { + const hasCustomized = result?.data?.has_customized; + if (hasCustomized) { + confirmAndTurnoff(); + } else { + proceedWithTurnoff(); + } + }) + .catch(() => { revertToggle(); - tutorConfirmTurnoffModal(message, title, cancelText, confirmText).then((confirmed) => { - if (confirmed) { - proceedWithTurnoff(); + }); + }); + }); + } else if (confirmationType === 'change') { + document.querySelectorAll(`#field_${fieldKey} select`).forEach((selectElement) => { + let previousValue = selectElement.value; + + selectElement.addEventListener('change', function () { + const newValue = this.value; + if (newValue === previousValue) { + return; + } + + const saveBtn = document.getElementById('save_tutor_option'); + const wasSaveDisabled = saveBtn ? saveBtn.disabled : false; + + const revertSelect = () => { + this.value = previousValue; + + const customSelect = this.nextElementSibling; + if (customSelect && customSelect.classList.contains('tutor-js-form-select')) { + const selectLabel = customSelect.querySelector('.tutor-form-select-label'); + const selectedOption = Array.from(this.options).find((opt) => opt.value === previousValue); + if (selectLabel && selectedOption) { + selectLabel.innerText = selectedOption.text; + selectLabel.dataset.value = previousValue; + } + const optionsWrap = customSelect.querySelector('.tutor-form-select-options'); + if (optionsWrap) { + optionsWrap.querySelector('.is-active')?.classList.remove('is-active'); + const prevItem = optionsWrap.querySelector(`[data-key="${previousValue}"]`); + if (prevItem) { + prevItem.classList.add('is-active'); } - }); + } + } + + if (saveBtn) { + saveBtn.disabled = wasSaveDisabled; + } + }; + + tutorConfirmOptionModal(message, title, cancelText, confirmText).then((confirmed) => { + if (confirmed) { + previousValue = newValue; + if (saveBtn) { + saveBtn.disabled = false; + } } else { - proceedWithTurnoff(); + revertSelect(); } - }) - .catch(() => { - revertToggle(); }); + }); }); - }); + } }); }); /** - * Show a confirmation modal for toggle turn-off. + * Show a confirmation modal for option changes or turn-offs. * * @since 4.1.0 * @@ -822,7 +875,7 @@ document.addEventListener('DOMContentLoaded', function () { * @param {string} [confirmText] Optional confirm button label. * @return {Promise} Resolves true if confirmed, false if cancelled. */ -function tutorConfirmTurnoffModal(message, title, cancelText, confirmText) { +function tutorConfirmOptionModal(message, title, cancelText, confirmText) { const { __ } = wp.i18n; return new Promise((resolve) => { @@ -839,7 +892,7 @@ function tutorConfirmTurnoffModal(message, title, cancelText, confirmText) { }; popup = new window.tutor_popup(window.jQuery, '').popup({ - title: title || __('Turn off setting?', 'tutor'), + title: title || __('Confirm setting?', 'tutor'), description: message, buttons: { cancel: { diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx new file mode 100644 index 0000000000..f02007d1c4 --- /dev/null +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/NegativeMarkTypeMismatchNotice.tsx @@ -0,0 +1,180 @@ +import { useFormContext } from 'react-hook-form'; +import { css } from '@emotion/react'; +import { __, sprintf } from '@wordpress/i18n'; + +import Alert from '@TutorShared/atoms/Alert'; +import Button from '@TutorShared/atoms/Button'; + +import ConfirmationModal from '@TutorShared/components/modals/ConfirmationModal'; +import { useModal } from '@TutorShared/components/modals/Modal'; + +import { tutorConfig } from '@TutorShared/config/config'; +import { DEFAULT_QUIZ_NEGATIVE_MARK_VALUE, QUIZ_NEGATIVE_MARK_TYPES } from '@TutorShared/config/constants'; +import { colorTokens, spacing } from '@TutorShared/config/styles'; +import { typography } from '@TutorShared/config/typography'; + +import type { QuizForm } from '@CourseBuilderServices/quiz'; + +interface NegativeMarkTypeMismatchModalContentProps { + isSwitchingToPoints: boolean; + currentPenalty: number | string; + adminNegativeMarkValue: number | string; +} + +const NegativeMarkTypeMismatchModalContent = ({ + isSwitchingToPoints, + currentPenalty, + adminNegativeMarkValue, +}: NegativeMarkTypeMismatchModalContentProps) => { + const descriptionHtml = isSwitchingToPoints + ? sprintf( + // translators: 1: current penalty percentage (e.g. 15%), 2: new penalty placeholder ({new penalty}) + __('The penalty per wrong answer will change from %1$s to %2$s points.', 'tutor'), + `${currentPenalty}%`, + adminNegativeMarkValue, + ) + : sprintf( + // translators: 1: current penalty points (e.g. 15), 2: new penalty placeholder ({new penalty}) + __('The penalty per wrong answer will change from %1$s points to %2$s%%.', 'tutor'), + currentPenalty, + adminNegativeMarkValue, + ); + + return ( +
+

+ +

+
+ ); +}; + +const NegativeMarkTypeMismatchNotice = () => { + const form = useFormContext(); + const { showModal } = useModal(); + + const negativeMarkType = form.watch('quiz_option.negative_mark_type'); + const negativeMarkingEnabled = form.watch('quiz_option.enable_negative_marking'); + const negativeMarkValue = form.watch('quiz_option.negative_mark_value'); + + const adminNegativeMarkValue = tutorConfig.settings.quiz_negative_mark_value; + const adminNegativeMarkType = + tutorConfig.settings?.quiz_negative_mark_type === QUIZ_NEGATIVE_MARK_TYPES.FIXED + ? QUIZ_NEGATIVE_MARK_TYPES.FIXED + : QUIZ_NEGATIVE_MARK_TYPES.PERCENT; + + const showMismatchNotice = Boolean(negativeMarkingEnabled && adminNegativeMarkType !== negativeMarkType); + + if (!showMismatchNotice) { + return null; + } + + const isSwitchingToPoints = adminNegativeMarkType === QUIZ_NEGATIVE_MARK_TYPES.FIXED; + + const mismatchNoticeText = isSwitchingToPoints + ? sprintf( + // translators: %s is the current percentage penalty, e.g. 15 + __('The default penalty unit is now points. This quiz still uses a %s%% penalty per wrong answer.', 'tutor'), + negativeMarkValue ?? 0, + ) + : sprintf( + // translators: %s is the current points penalty, e.g. 15 + __( + 'The default penalty unit is now percentage. This quiz still uses a %s pts penalty per wrong answer.', + 'tutor', + ), + negativeMarkValue ?? 0, + ); + + const handleOpenConfirmModal = async () => { + const result = await showModal({ + component: ConfirmationModal, + props: { + title: isSwitchingToPoints + ? __('Switch penalty unit to points?', 'tutor') + : __('Switch penalty unit to percentage?', 'tutor'), + confirmButtonText: isSwitchingToPoints ? __('Switch to Points', 'tutor') : __('Switch to Percentage', 'tutor'), + confirmButtonVariant: 'primary', + cancelButtonText: __('Cancel', 'tutor'), + maxWidth: 480, + description: ( + + ), + }, + closeOnOutsideClick: true, + closeOnEscape: true, + }); + + if (result?.action === 'CONFIRM') { + form.setValue('quiz_option.negative_mark_type', adminNegativeMarkType, { + shouldDirty: true, + shouldValidate: true, + }); + form.setValue('quiz_option.negative_mark_value', adminNegativeMarkValue, { + shouldDirty: true, + shouldValidate: true, + }); + } + }; + + return ( +
+ + {__('Update', 'tutor')} + + } + > + {mismatchNoticeText} + +
+ ); +}; + +export default NegativeMarkTypeMismatchNotice; + +const styles = { + noticeWrapper: css` + width: 100%; + margin-top: ${spacing[4]}; + `, + confirmModalContent: css` + display: flex; + flex-direction: column; + gap: ${spacing[16]}; + `, + confirmModalDescription: css` + ${typography.small()}; + color: ${colorTokens.text.subdued}; + margin: 0; + + strong { + color: ${colorTokens.text.title}; + } + `, + confirmModalBulletList: css` + margin: 0; + padding-inline-start: ${spacing[20]}; + list-style-type: disc; + color: ${colorTokens.text.subdued}; + + li { + color: ${colorTokens.text.subdued}; + list-style-type: disc; + + &:last-child { + margin-bottom: 0; + } + } + `, +}; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 0b5db8e87c..555e81a87d 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -36,6 +36,7 @@ import QuizFullPageSvg from '@SharedImages/quiz-fullpage.svg'; import QuizSingleLayoutSvg from '@SharedImages/quiz-single-question.svg'; import FormQuizLayoutSelect from './FormQuizLayoutSelect'; +import NegativeMarkTypeMismatchNotice from './NegativeMarkTypeMismatchNotice'; const courseId = getCourseId(); @@ -360,29 +361,33 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
-
- ( - - )} - /> +
+
+ ( + + )} + /> +
{ + if (isNaN(Number(value))) return __('Must be a number', 'tutor'); const numericValue = Number(value); - if (numericValue < 0) return __('Negative mark value cannot be less than 0', 'tutor'); + if (numericValue <= 0) return __('Cannot be less than or equal to 0', 'tutor'); if (negativeMarkType === QUIZ_NEGATIVE_MARK_TYPES.PERCENT && numericValue > 100) - return __('Percentage penalty cannot be greater than 100', 'tutor'); + return __('Cannot be greater than 100', 'tutor'); return true; }, }} @@ -393,9 +398,10 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { size="small" isInlineLabel disabled={!adminNegativeEnabled} - wrapperCss={styles.maxWidth('80px')} + wrapperCss={styles.negativeMarkingInput} contentCss={styles.minWidth('fit-content')} - formFieldWrapperCss={styles.width('auto')} + formFieldWrapperCss={styles.negativeMarkingFieldWrapper} + inputContainerCss={styles.justifyContent('flex-end')} content={negativeMarkType === QUIZ_NEGATIVE_MARK_TYPES.PERCENT ? '%' : __('pts', 'tutor')} contentPosition="right" showVerticalBar={false} @@ -404,6 +410,8 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { />
+ +
@@ -1072,6 +1080,37 @@ const styles = { border-radius: ${borderRadius[8]}; background-color: ${colorTokens.surface.courseBuilder}; `, + negativeMarkingRow: css` + ${styleUtils.display.flex('row')}; + width: 100%; + align-items: flex-start; + justify-content: space-between; + gap: ${spacing[8]}; + min-height: 34px; + `, + negativeMarkingCheckbox: css` + display: flex; + align-items: center; + min-height: 34px; + width: auto; + + [data-cy='form-field-wrapper'] { + width: auto; + } + `, + negativeMarkingFieldWrapper: css` + width: auto; + align-items: flex-end; + margin-left: auto; + + p { + text-align: right; + } + `, + negativeMarkingInput: css` + max-width: 80px; + margin-left: auto; + `, inlineForm: ({ withPrefix, minHeight }: { withPrefix?: boolean; minHeight?: string } = {}) => css` ${styleUtils.display.flex('row')}; width: 100%; diff --git a/assets/src/js/v3/entries/course-builder/services/quiz.ts b/assets/src/js/v3/entries/course-builder/services/quiz.ts index 6fa28746b7..e9b71b7241 100644 --- a/assets/src/js/v3/entries/course-builder/services/quiz.ts +++ b/assets/src/js/v3/entries/course-builder/services/quiz.ts @@ -8,7 +8,6 @@ import { tutorConfig } from '@TutorShared/config/config'; import { Addons, DEFAULT_QUIZ_ATTEMPTS_ALLOWED, - DEFAULT_QUIZ_NEGATIVE_MARK_TYPE, DEFAULT_QUIZ_NEGATIVE_MARK_VALUE, QUIZ_NEGATIVE_MARK_TYPES, type QuizNegativeMarkType, @@ -231,7 +230,7 @@ export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse, slotFie quiz.quiz_option.negative_mark_type ?? (tutorConfig.settings?.quiz_negative_mark_type === QUIZ_NEGATIVE_MARK_TYPES.FIXED ? QUIZ_NEGATIVE_MARK_TYPES.FIXED - : DEFAULT_QUIZ_NEGATIVE_MARK_TYPE), + : QUIZ_NEGATIVE_MARK_TYPES.PERCENT), negative_mark_value: quiz.quiz_option.negative_mark_value ?? Number(tutorConfig.settings?.quiz_negative_mark_value ?? DEFAULT_QUIZ_NEGATIVE_MARK_VALUE), diff --git a/assets/src/js/v3/shared/atoms/Alert.tsx b/assets/src/js/v3/shared/atoms/Alert.tsx index 1bbb6eeddb..6668deb97d 100644 --- a/assets/src/js/v3/shared/atoms/Alert.tsx +++ b/assets/src/js/v3/shared/atoms/Alert.tsx @@ -1,4 +1,5 @@ -import { css } from '@emotion/react'; +import type React from 'react'; +import { css, type SerializedStyles } from '@emotion/react'; import { borderRadius, spacing } from '@TutorShared/config/styles'; import { typography } from '@TutorShared/config/typography'; @@ -13,6 +14,8 @@ interface AlertProps { children: React.ReactNode; type?: AlertType; icon?: IconCollection; + action?: React.ReactNode; + wrapperCss?: SerializedStyles; } const alertStyles = { @@ -20,32 +23,39 @@ const alertStyles = { warning: '#D47E00', success: '#D47E00', danger: '#f44337', - info: '#D47E00', - primary: '#D47E00', + info: '#2B49CA', + primary: '#2B49CA', }, icon: { warning: '#FAB000', success: '#FAB000', danger: '#f55e53', - info: '#FAB000', - primary: '#FAB000', + info: '#2B49CA', + primary: '#2B49CA', }, background: { warning: '#FBFAE9', success: '#FBFAE9', danger: '#fdd9d7', - info: '#FBFAE9', - primary: '#FBFAE9', + info: '#E4EBFC', + primary: '#E4EBFC', }, }; -const Alert = ({ children, type = 'warning', icon }: AlertProps) => { +const Alert = ({ children, type = 'warning', icon, action, wrapperCss }: AlertProps) => { + const hasAction = !!action; + return ( -
- - {(iconName) => } +
+
+ + {(iconName) => } + + {children} +
+ +
{action}
- {children}
); }; @@ -53,20 +63,41 @@ const Alert = ({ children, type = 'warning', icon }: AlertProps) => { export default Alert; const styles = { - wrapper: ({ type }: { type: AlertType }) => css` + wrapper: ({ type, hasAction }: { type: AlertType; hasAction?: boolean }) => css` ${typography.caption()}; display: flex; - align-items: start; - padding: ${spacing[8]} ${spacing[12]}; + align-items: ${hasAction ? 'center' : 'start'}; + justify-content: ${hasAction ? 'space-between' : 'flex-start'}; + padding: ${hasAction ? `${spacing[8]}` : `${spacing[8]} ${spacing[12]}`}; width: 100%; border-radius: ${borderRadius.card}; - gap: ${spacing[4]}; + gap: ${hasAction ? spacing[12] : spacing[4]}; background-color: ${alertStyles.background[type]}; color: ${alertStyles.text[type]}; `, + content: css` + display: flex; + align-items: start; + gap: ${spacing[4]}; + flex: 1; + `, + + text: ({ hasAction }: { hasAction?: boolean }) => css` + ${hasAction && + css` + ${typography.small()}; + color: inherit; + text-wrap: pretty; + `} + `, + icon: ({ type }: { type: AlertType }) => css` color: ${alertStyles.icon[type]}; flex-shrink: 0; `, + + action: css` + flex-shrink: 0; + `, }; diff --git a/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx b/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx index 07ad0fda92..98d6432249 100644 --- a/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx +++ b/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx @@ -27,6 +27,7 @@ interface FormInputWithContentProps extends FormControllerProps {(inputProps) => { const { css: inputCss, ...restInputProps } = inputProps; diff --git a/classes/Quiz.php b/classes/Quiz.php index d44a4d4697..8ec7c3c061 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1109,7 +1109,7 @@ function ( $ans ) { $attempt_info = array( 'total_answered_questions' => tutor_utils()->count( $quiz_answers ), - 'earned_marks' => $total_marks, + 'earned_marks' => max( 0.0, $total_marks ), 'attempt_status' => QuizModel::ATTEMPT_ENDED, 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore ); @@ -1300,7 +1300,7 @@ public function review_quiz_answer() { QueryHelper::update( 'tutor_quiz_attempts', array( - 'earned_marks' => max( 0, (float) $attempt->earned_marks + $mark_delta ), + 'earned_marks' => $this->calculate_attempt_earned_marks( $attempt_id ), 'is_manually_reviewed' => 1, 'manually_reviewed_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), 'attempt_status' => QuizModel::ATTEMPT_ENDED, @@ -1505,7 +1505,6 @@ private function apply_manual_quiz_answer_mark( $attempt_answer, $mark ) { * @return void */ private function apply_manual_marks_bulk( int $attempt_id, array $manual_marks, array $answers_by_question_id ): void { - $delta = 0.0; $applied = false; foreach ( $manual_marks as $question_id => $mark ) { @@ -1519,7 +1518,6 @@ private function apply_manual_marks_bulk( int $attempt_id, array $manual_marks, if ( null !== $mark_delta ) { $applied = true; - $delta += $mark_delta; } } @@ -1532,7 +1530,7 @@ private function apply_manual_marks_bulk( int $attempt_id, array $manual_marks, QueryHelper::update( 'tutor_quiz_attempts', array( - 'earned_marks' => max( 0, (float) $attempt->earned_marks + $delta ), + 'earned_marks' => $this->calculate_attempt_earned_marks( $attempt_id ), 'is_manually_reviewed' => 1, 'manually_reviewed_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), 'attempt_status' => QuizModel::ATTEMPT_ENDED, @@ -1804,7 +1802,6 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str $mark_as = apply_filters( 'tutor_quiz_review_mark_as', $mark_as, $attempt_answer_id, $attempt_id, $question ); $attempt_update_data = array(); - $previous_achieved = (float) ( $attempt_answer->achieved_mark ?? 0.0 ); $question_mark = (float) ( $attempt_answer->question_mark ?? $question->question_mark ?? 0.0 ); $default_marks = array( @@ -1823,7 +1820,6 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str $new_achieved = (float) ( $review_marks['achieved_mark'] ?? $default_marks['achieved_mark'] ); $new_minus = (float) ( $review_marks['minus_mark'] ?? $default_marks['minus_mark'] ); - $mark_diff = $new_achieved - $previous_achieved; $answer_update_data = array( 'achieved_mark' => $new_achieved, @@ -1834,7 +1830,7 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) ); $attempt_update_data = array( - 'earned_marks' => max( 0.0, (float) $attempt->earned_marks + $mark_diff ), + 'earned_marks' => $this->calculate_attempt_earned_marks( $attempt_id ), 'is_manually_reviewed' => 1, 'manually_reviewed_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), ); @@ -1870,6 +1866,27 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str ); } + /** + * Calculate total earned marks for an attempt from individual answer records. + * + * @since 4.1.0 + * + * @param int $attempt_id Attempt ID. + * + * @return float + */ + private function calculate_attempt_earned_marks( int $attempt_id ): float { + $marks = QueryHelper::query( + 'tutor_quiz_attempt_answers', + array( + 'select' => 'SUM(achieved_mark) AS earned_marks', + 'where' => array( 'quiz_attempt_id' => $attempt_id ), + ) + ); + + return max( 0.0, (float) ( $marks[0]->earned_marks ?? 0.0 ) ); + } + /** * Do auto course complete after review a quiz attempt. * @@ -2292,7 +2309,7 @@ public static function render_quiz_summary( $total_questions, $quiz_item_readabl ), ); - if ( $earned_marks ) { + if ( null !== $earned_marks ) { $quiz_summary[] = array( 'columns' => array( array( diff --git a/templates/learning-area/quiz/content.php b/templates/learning-area/quiz/content.php index 7776fb5951..64a54db9de 100644 --- a/templates/learning-area/quiz/content.php +++ b/templates/learning-area/quiz/content.php @@ -31,7 +31,7 @@ $time_units = Quiz::quiz_time_units(); $quiz_item_readable = $has_time_limit ? $quiz_time['time_value'] . ' ' . $time_units[ $quiz_time['time_type'] ] : null; $quiz_attempt = ( new QuizModel() )->get_quiz_attempt( $quiz_id, $user_id ?? get_current_user_id() ); -$earned_marks = 0; +$earned_marks = null; $total_marks = Quiz::get_quiz_total_marks( $quiz_id ); if ( is_object( $quiz_attempt ) && (float) ( $quiz_attempt->total_marks ?? 0 ) > 0 ) { diff --git a/views/quiz/attempt-table.php b/views/quiz/attempt-table.php index 7da554af37..7e4e0deefb 100644 --- a/views/quiz/attempt-table.php +++ b/views/quiz/attempt-table.php @@ -131,7 +131,7 @@ - earned_marks ) ? round( $attempt->earned_marks ) . ' (' . $earned_percentage . '%)' : '0 (0%)' ); ?> + earned_marks ) ? round( (float) $attempt->earned_marks, 2 ) . ' (' . $earned_percentage . '%)' : '0 (0%)' ); ?>