AutorÃa | Ultima modificación | Ver Log |
// 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/>./*** This module provides functionality for managing weight calculations and adjustments for grade items.** @module core_grades/edittree_weight* @copyright 2023 Shamim Rezaie <shamim@moodle.com>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/import {getString} from 'core/str';import {prefetchStrings} from 'core/prefetch';/*** Selectors.** @type {Object}*/const selectors = {weightOverrideCheckbox: 'input[type="checkbox"][name^="weightoverride_"]',weightOverrideInput: 'input[type="text"][name^="weight_"]',aggregationForCategory: category => `[data-aggregationforcategory='${category}']`,childrenByCategory: category => `tr[data-parent-category="${category}"]`,categoryByIdentifier: identifier => `tr.category[data-category="${identifier}"]`,};/*** An object representing grading-related constants.* The same as what's defined in lib/grade/constants.php.** @type {Object}* @property {Object} aggregation Aggregation settings.* @property {number} aggregation.sum Aggregation method: sum.* @property {Object} type Grade type settings.* @property {number} type.none Grade type: none.* @property {number} type.value Grade type: value.* @property {number} type.scale Grade type: scale.*/const grade = {aggregation: {sum: 13,},};/*** The character used as the decimal separator for number formatting.** @type {string}*/let decimalSeparator;/*** This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.* Even though the old algorithm has bugs in it, we need to preserve existing grades.** @type {boolean}*/let oldExtraCreditCalculation;/*** Recalculates the natural weights for grade items within a given category.** @param {HTMLElement} categoryElement The DOM element representing the category.*/// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.// eslint-disable-next-line complexityconst recalculateNaturalWeights = (categoryElement) => {const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));// Calculate the sum of the grademax's of all the items within this category.let totalGradeMax = 0;// Out of 100, how much weight has been manually overridden by a user?let totalOverriddenWeight = 0;let totalOverriddenGradeMax = 0;// Has every assessment in this category been overridden?let automaticGradeItemsPresent = false;// Does the grade item require normalising?let requiresNormalising = false;// Is there an error in the weight calculations?let erroneous = false;// This array keeps track of the id and weight of every grade item that has been overridden.const overrideArray = {};for (const childElement of childElements) {const weightInput = childElement.querySelector(selectors.weightOverrideInput);const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);// There are cases where a grade item should be excluded from calculations:// - If the item's grade type is 'text' or 'none'.// - If the grade item is an outcome item and the settings are set to not aggregate outcome items.// - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.// All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page// if a grade item should not have a weight.if (!weightInput) {continue;}const itemWeight = parseWeight(weightInput.value);const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);const itemGradeMax = parseFloat(childElement.dataset.grademax);// Record the ID and the weight for this grade item.overrideArray[childElement.dataset.itemid] = {extraCredit: itemAggregationCoefficient,weight: itemWeight,weightOverride: weightCheckbox.checked,};// If this item has had its weight overridden then set the flag to true, but// only if all previous items were also overridden. Note that extra credit items// are counted as overridden grade items.if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {automaticGradeItemsPresent = true;}if (itemAggregationCoefficient > 0) {// An extra credit grade item doesn't contribute to totalOverriddenGradeMax.continue;} else if (weightCheckbox.checked && itemWeight <= 0) {// An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.continue;}totalGradeMax += itemGradeMax;if (weightCheckbox.checked) {totalOverriddenWeight += itemWeight;totalOverriddenGradeMax += itemGradeMax;}}// Initialise this variable (used to keep track of the weight override total).let normaliseTotal = 0;// Keep a record of how much the override total is to see if it is above 100. If it is then we need to set the// other weights to zero and normalise the others.let overriddenTotal = 0;// Total up all the weights.for (const gradeItemDetail of Object.values(overrideArray)) {// Exclude grade items with extra credit or negative weights (which will be set to zero later).if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {normaliseTotal += gradeItemDetail.weight;}// The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {// Add overridden weights up to see if they are greater than 1.overriddenTotal += gradeItemDetail.weight;}}if (overriddenTotal > 100) {// Make sure that this category of weights gets normalised.requiresNormalising = true;// The normalised weights are only the overridden weights, so we just use the total of those.normaliseTotal = overriddenTotal;}const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;for (const childElement of childElements) {const weightInput = childElement.querySelector(selectors.weightOverrideInput);const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);const itemGradeMax = parseFloat(childElement.dataset.grademax);if (!weightInput) {continue;} else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {// For an item with extra credit ignore other weights and overrides but do not change anything at all// if its weight was already overridden.continue;}// Remove any error messages and classes.weightInput.classList.remove('is-invalid');const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');errorArea.textContent = '';if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {// For an item with extra credit ignore other weights and overrides.weightInput.value = totalGradeMax ? formatFloat(itemGradeMax * 100 / totalGradeMax) : 0;} else if (!weightCheckbox.checked) {// Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {// There is no more weight to distribute.weightInput.value = formatFloat(0);} else {// Calculate this item's weight as a percentage of the non-overridden total grade maxes// then convert it to a proportion of the available non-overridden weight.weightInput.value = formatFloat((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));}} else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || requiresNormalising ||overrideArray[childElement.dataset.itemid].weight < 0) {if (overrideArray[childElement.dataset.itemid].weight < 0) {weightInput.value = formatFloat(0);}// Zero is a special case. If the total is zero then we need to set the weight of the parent category to zero.if (normaliseTotal !== 0) {erroneous = true;const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';// eslint-disable-next-line promise/always-return,promise/catch-or-returngetString(error, 'core_grades').then((errorString) => {errorArea.textContent = errorString;});weightInput.classList.add('is-invalid');}}}if (!erroneous) {const categoryGradeMax = parseFloat(categoryElement.dataset.grademax);if (categoryGradeMax !== totalGradeMax) {// The category grade max is not the same as the total grade max, so we need to update the category grade max.categoryElement.dataset.grademax = totalGradeMax;const relatedCategoryAggregationRow = document.querySelector(selectors.aggregationForCategory(categoryElement.dataset.category));relatedCategoryAggregationRow.querySelector('.column-range').innerHTML = formatFloat(totalGradeMax, 2, 2);const parentCategory = document.querySelector(selectors.categoryByIdentifier(categoryElement.dataset.parentCategory));if (parentCategory && (parseInt(parentCategory.dataset.aggregation) === grade.aggregation.sum)) {recalculateNaturalWeights(parentCategory);}}}};/*** Formats a floating-point number as a string with the specified number of decimal places.* Unnecessary trailing zeros are removed up to the specified minimum number of decimal places.** @param {number} number The float value to be formatted.* @param {number} [decimalPoints=3] The number of decimal places to use.* @param {number} [minDecimals=1] The minimum number of decimal places to use.* @returns {string} The formatted weight value with the specified decimal places.*/const formatFloat = (number, decimalPoints = 3, minDecimals = 1) => {return number.toFixed(decimalPoints).replace(new RegExp(`0{0,${decimalPoints - minDecimals}}$`), '').replace('.', decimalSeparator);};/*** Parses a weight string and returns a normalized float value.** @param {string} weightString The weight as a string, possibly with localized formatting.* @returns {number} The parsed weight as a float. If parsing fails, returns 0.*/const parseWeight = (weightString) => {const normalizedWeightString = weightString.replace(decimalSeparator, '.');return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);};/*** Initializes the weight management module with optional configuration.** @param {string} decSep The character used as the decimal separator for number formatting.* @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.*/export const init = (decSep, oldCalculation) => {decimalSeparator = decSep;oldExtraCreditCalculation = oldCalculation;prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);document.addEventListener('change', e => {// Update the weights of all grade items in the category when the weight of any grade item in the category is changed.if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {// The following is named gradeItemRow, but it may also be a row that's representing a grade category.// It's ok because it serves as the categories associated grade item in our calculations.const gradeItemRow = e.target.closest('tr');const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));// This is only required if we are using natural weights.if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);weightElement.value = formatFloat(Math.max(0, parseWeight(weightElement.value)));recalculateNaturalWeights(categoryElement);}}});document.addEventListener('submit', e => {// If the form is being submitted, then we need to ensure that the weight input fields are all set to// a valid value.if (e.target.matches('#gradetreeform')) {const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');if (firstInvalidWeightInput) {const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');if (firstFocusableInvalidWeightInput) {firstFocusableInvalidWeightInput.focus();} else {firstInvalidWeightInput.scrollIntoView({block: 'center'});}e.preventDefault();}}});};