1 |
efrain |
1 |
// This file is part of Moodle - http://moodle.org/
|
|
|
2 |
//
|
|
|
3 |
// Moodle is free software: you can redistribute it and/or modify
|
|
|
4 |
// it under the terms of the GNU General Public License as published by
|
|
|
5 |
// the Free Software Foundation, either version 3 of the License, or
|
|
|
6 |
// (at your option) any later version.
|
|
|
7 |
//
|
|
|
8 |
// Moodle is distributed in the hope that it will be useful,
|
|
|
9 |
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
10 |
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
11 |
// GNU General Public License for more details.
|
|
|
12 |
//
|
|
|
13 |
// You should have received a copy of the GNU General Public License
|
|
|
14 |
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
|
|
15 |
|
|
|
16 |
/**
|
|
|
17 |
* This module provides functionality for managing weight calculations and adjustments for grade items.
|
|
|
18 |
*
|
|
|
19 |
* @module core_grades/edittree_weight
|
|
|
20 |
* @copyright 2023 Shamim Rezaie <shamim@moodle.com>
|
|
|
21 |
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
|
22 |
*/
|
|
|
23 |
|
|
|
24 |
import {getString} from 'core/str';
|
|
|
25 |
import {prefetchStrings} from 'core/prefetch';
|
|
|
26 |
|
|
|
27 |
/**
|
|
|
28 |
* Selectors.
|
|
|
29 |
*
|
|
|
30 |
* @type {Object}
|
|
|
31 |
*/
|
|
|
32 |
const selectors = {
|
|
|
33 |
weightOverrideCheckbox: 'input[type="checkbox"][name^="weightoverride_"]',
|
|
|
34 |
weightOverrideInput: 'input[type="text"][name^="weight_"]',
|
|
|
35 |
aggregationForCategory: category => `[data-aggregationforcategory='${category}']`,
|
|
|
36 |
childrenByCategory: category => `tr[data-parent-category="${category}"]`,
|
|
|
37 |
categoryByIdentifier: identifier => `tr.category[data-category="${identifier}"]`,
|
|
|
38 |
};
|
|
|
39 |
|
|
|
40 |
/**
|
|
|
41 |
* An object representing grading-related constants.
|
|
|
42 |
* The same as what's defined in lib/grade/constants.php.
|
|
|
43 |
*
|
|
|
44 |
* @type {Object}
|
|
|
45 |
* @property {Object} aggregation Aggregation settings.
|
|
|
46 |
* @property {number} aggregation.sum Aggregation method: sum.
|
|
|
47 |
* @property {Object} type Grade type settings.
|
|
|
48 |
* @property {number} type.none Grade type: none.
|
|
|
49 |
* @property {number} type.value Grade type: value.
|
|
|
50 |
* @property {number} type.scale Grade type: scale.
|
|
|
51 |
*/
|
|
|
52 |
const grade = {
|
|
|
53 |
aggregation: {
|
|
|
54 |
sum: 13,
|
|
|
55 |
},
|
|
|
56 |
};
|
|
|
57 |
|
|
|
58 |
/**
|
|
|
59 |
* The character used as the decimal separator for number formatting.
|
|
|
60 |
*
|
|
|
61 |
* @type {string}
|
|
|
62 |
*/
|
|
|
63 |
let decimalSeparator;
|
|
|
64 |
|
|
|
65 |
/**
|
|
|
66 |
* This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.
|
|
|
67 |
* Even though the old algorithm has bugs in it, we need to preserve existing grades.
|
|
|
68 |
*
|
|
|
69 |
* @type {boolean}
|
|
|
70 |
*/
|
|
|
71 |
let oldExtraCreditCalculation;
|
|
|
72 |
|
|
|
73 |
/**
|
|
|
74 |
* Recalculates the natural weights for grade items within a given category.
|
|
|
75 |
*
|
|
|
76 |
* @param {HTMLElement} categoryElement The DOM element representing the category.
|
|
|
77 |
*/
|
|
|
78 |
// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.
|
|
|
79 |
// eslint-disable-next-line complexity
|
|
|
80 |
const recalculateNaturalWeights = (categoryElement) => {
|
|
|
81 |
const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));
|
|
|
82 |
|
|
|
83 |
// Calculate the sum of the grademax's of all the items within this category.
|
|
|
84 |
let totalGradeMax = 0;
|
|
|
85 |
|
|
|
86 |
// Out of 100, how much weight has been manually overridden by a user?
|
|
|
87 |
let totalOverriddenWeight = 0;
|
|
|
88 |
let totalOverriddenGradeMax = 0;
|
|
|
89 |
|
|
|
90 |
// Has every assessment in this category been overridden?
|
|
|
91 |
let automaticGradeItemsPresent = false;
|
|
|
92 |
// Does the grade item require normalising?
|
|
|
93 |
let requiresNormalising = false;
|
|
|
94 |
|
|
|
95 |
// Is there an error in the weight calculations?
|
|
|
96 |
let erroneous = false;
|
|
|
97 |
|
|
|
98 |
// This array keeps track of the id and weight of every grade item that has been overridden.
|
|
|
99 |
const overrideArray = {};
|
|
|
100 |
|
|
|
101 |
for (const childElement of childElements) {
|
|
|
102 |
const weightInput = childElement.querySelector(selectors.weightOverrideInput);
|
|
|
103 |
const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);
|
|
|
104 |
|
|
|
105 |
// There are cases where a grade item should be excluded from calculations:
|
|
|
106 |
// - If the item's grade type is 'text' or 'none'.
|
|
|
107 |
// - If the grade item is an outcome item and the settings are set to not aggregate outcome items.
|
|
|
108 |
// - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.
|
|
|
109 |
// All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page
|
|
|
110 |
// if a grade item should not have a weight.
|
|
|
111 |
if (!weightInput) {
|
|
|
112 |
continue;
|
|
|
113 |
}
|
|
|
114 |
|
|
|
115 |
const itemWeight = parseWeight(weightInput.value);
|
|
|
116 |
const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);
|
|
|
117 |
const itemGradeMax = parseFloat(childElement.dataset.grademax);
|
|
|
118 |
|
|
|
119 |
// Record the ID and the weight for this grade item.
|
|
|
120 |
overrideArray[childElement.dataset.itemid] = {
|
|
|
121 |
extraCredit: itemAggregationCoefficient,
|
|
|
122 |
weight: itemWeight,
|
|
|
123 |
weightOverride: weightCheckbox.checked,
|
|
|
124 |
};
|
|
|
125 |
// If this item has had its weight overridden then set the flag to true, but
|
|
|
126 |
// only if all previous items were also overridden. Note that extra credit items
|
|
|
127 |
// are counted as overridden grade items.
|
|
|
128 |
if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {
|
|
|
129 |
automaticGradeItemsPresent = true;
|
|
|
130 |
}
|
|
|
131 |
|
|
|
132 |
if (itemAggregationCoefficient > 0) {
|
|
|
133 |
// An extra credit grade item doesn't contribute to totalOverriddenGradeMax.
|
|
|
134 |
continue;
|
|
|
135 |
} else if (weightCheckbox.checked && itemWeight <= 0) {
|
|
|
136 |
// An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.
|
|
|
137 |
continue;
|
|
|
138 |
}
|
|
|
139 |
|
|
|
140 |
totalGradeMax += itemGradeMax;
|
|
|
141 |
if (weightCheckbox.checked) {
|
|
|
142 |
totalOverriddenWeight += itemWeight;
|
|
|
143 |
totalOverriddenGradeMax += itemGradeMax;
|
|
|
144 |
}
|
|
|
145 |
}
|
|
|
146 |
|
|
|
147 |
// Initialise this variable (used to keep track of the weight override total).
|
|
|
148 |
let normaliseTotal = 0;
|
|
|
149 |
// 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
|
|
|
150 |
// other weights to zero and normalise the others.
|
|
|
151 |
let overriddenTotal = 0;
|
|
|
152 |
// Total up all the weights.
|
|
|
153 |
for (const gradeItemDetail of Object.values(overrideArray)) {
|
|
|
154 |
// Exclude grade items with extra credit or negative weights (which will be set to zero later).
|
|
|
155 |
if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {
|
|
|
156 |
normaliseTotal += gradeItemDetail.weight;
|
|
|
157 |
}
|
|
|
158 |
// The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.
|
|
|
159 |
if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {
|
|
|
160 |
// Add overridden weights up to see if they are greater than 1.
|
|
|
161 |
overriddenTotal += gradeItemDetail.weight;
|
|
|
162 |
}
|
|
|
163 |
}
|
|
|
164 |
if (overriddenTotal > 100) {
|
|
|
165 |
// Make sure that this category of weights gets normalised.
|
|
|
166 |
requiresNormalising = true;
|
|
|
167 |
// The normalised weights are only the overridden weights, so we just use the total of those.
|
|
|
168 |
normaliseTotal = overriddenTotal;
|
|
|
169 |
}
|
|
|
170 |
|
|
|
171 |
const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;
|
|
|
172 |
|
|
|
173 |
for (const childElement of childElements) {
|
|
|
174 |
const weightInput = childElement.querySelector(selectors.weightOverrideInput);
|
|
|
175 |
const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);
|
|
|
176 |
const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);
|
|
|
177 |
const itemGradeMax = parseFloat(childElement.dataset.grademax);
|
|
|
178 |
|
|
|
179 |
if (!weightInput) {
|
|
|
180 |
continue;
|
|
|
181 |
} else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {
|
|
|
182 |
// For an item with extra credit ignore other weights and overrides but do not change anything at all
|
|
|
183 |
// if its weight was already overridden.
|
|
|
184 |
continue;
|
|
|
185 |
}
|
|
|
186 |
|
|
|
187 |
// Remove any error messages and classes.
|
|
|
188 |
weightInput.classList.remove('is-invalid');
|
|
|
189 |
const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');
|
|
|
190 |
errorArea.textContent = '';
|
|
|
191 |
|
|
|
192 |
if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {
|
|
|
193 |
// For an item with extra credit ignore other weights and overrides.
|
|
|
194 |
weightInput.value = totalGradeMax ? formatFloat(itemGradeMax * 100 / totalGradeMax) : 0;
|
|
|
195 |
} else if (!weightCheckbox.checked) {
|
|
|
196 |
// Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.
|
|
|
197 |
if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {
|
|
|
198 |
// There is no more weight to distribute.
|
|
|
199 |
weightInput.value = formatFloat(0);
|
|
|
200 |
} else {
|
|
|
201 |
// Calculate this item's weight as a percentage of the non-overridden total grade maxes
|
|
|
202 |
// then convert it to a proportion of the available non-overridden weight.
|
|
|
203 |
weightInput.value = formatFloat((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));
|
|
|
204 |
}
|
|
|
205 |
} else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || requiresNormalising ||
|
|
|
206 |
overrideArray[childElement.dataset.itemid].weight < 0) {
|
|
|
207 |
if (overrideArray[childElement.dataset.itemid].weight < 0) {
|
|
|
208 |
weightInput.value = formatFloat(0);
|
|
|
209 |
}
|
|
|
210 |
|
|
|
211 |
// Zero is a special case. If the total is zero then we need to set the weight of the parent category to zero.
|
|
|
212 |
if (normaliseTotal !== 0) {
|
|
|
213 |
erroneous = true;
|
|
|
214 |
const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';
|
|
|
215 |
// eslint-disable-next-line promise/always-return,promise/catch-or-return
|
|
|
216 |
getString(error, 'core_grades').then((errorString) => {
|
|
|
217 |
errorArea.textContent = errorString;
|
|
|
218 |
});
|
|
|
219 |
weightInput.classList.add('is-invalid');
|
|
|
220 |
}
|
|
|
221 |
}
|
|
|
222 |
}
|
|
|
223 |
|
|
|
224 |
if (!erroneous) {
|
|
|
225 |
const categoryGradeMax = parseFloat(categoryElement.dataset.grademax);
|
|
|
226 |
if (categoryGradeMax !== totalGradeMax) {
|
|
|
227 |
// The category grade max is not the same as the total grade max, so we need to update the category grade max.
|
|
|
228 |
categoryElement.dataset.grademax = totalGradeMax;
|
|
|
229 |
const relatedCategoryAggregationRow = document.querySelector(
|
|
|
230 |
selectors.aggregationForCategory(categoryElement.dataset.category)
|
|
|
231 |
);
|
|
|
232 |
relatedCategoryAggregationRow.querySelector('.column-range').innerHTML = formatFloat(totalGradeMax, 2, 2);
|
|
|
233 |
|
|
|
234 |
const parentCategory = document.querySelector(selectors.categoryByIdentifier(categoryElement.dataset.parentCategory));
|
|
|
235 |
if (parentCategory && (parseInt(parentCategory.dataset.aggregation) === grade.aggregation.sum)) {
|
|
|
236 |
recalculateNaturalWeights(parentCategory);
|
|
|
237 |
}
|
|
|
238 |
}
|
|
|
239 |
}
|
|
|
240 |
};
|
|
|
241 |
|
|
|
242 |
/**
|
|
|
243 |
* Formats a floating-point number as a string with the specified number of decimal places.
|
|
|
244 |
* Unnecessary trailing zeros are removed up to the specified minimum number of decimal places.
|
|
|
245 |
*
|
|
|
246 |
* @param {number} number The float value to be formatted.
|
|
|
247 |
* @param {number} [decimalPoints=3] The number of decimal places to use.
|
|
|
248 |
* @param {number} [minDecimals=1] The minimum number of decimal places to use.
|
|
|
249 |
* @returns {string} The formatted weight value with the specified decimal places.
|
|
|
250 |
*/
|
|
|
251 |
const formatFloat = (number, decimalPoints = 3, minDecimals = 1) => {
|
|
|
252 |
return number.toFixed(decimalPoints)
|
|
|
253 |
.replace(new RegExp(`0{0,${decimalPoints - minDecimals}}$`), '')
|
|
|
254 |
.replace('.', decimalSeparator);
|
|
|
255 |
};
|
|
|
256 |
|
|
|
257 |
/**
|
|
|
258 |
* Parses a weight string and returns a normalized float value.
|
|
|
259 |
*
|
|
|
260 |
* @param {string} weightString The weight as a string, possibly with localized formatting.
|
|
|
261 |
* @returns {number} The parsed weight as a float. If parsing fails, returns 0.
|
|
|
262 |
*/
|
|
|
263 |
const parseWeight = (weightString) => {
|
|
|
264 |
const normalizedWeightString = weightString.replace(decimalSeparator, '.');
|
|
|
265 |
return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);
|
|
|
266 |
};
|
|
|
267 |
|
|
|
268 |
/**
|
|
|
269 |
* Initializes the weight management module with optional configuration.
|
|
|
270 |
*
|
|
|
271 |
* @param {string} decSep The character used as the decimal separator for number formatting.
|
|
|
272 |
* @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.
|
|
|
273 |
*/
|
|
|
274 |
export const init = (decSep, oldCalculation) => {
|
|
|
275 |
decimalSeparator = decSep;
|
|
|
276 |
oldExtraCreditCalculation = oldCalculation;
|
|
|
277 |
prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);
|
|
|
278 |
|
|
|
279 |
document.addEventListener('change', e => {
|
|
|
280 |
// Update the weights of all grade items in the category when the weight of any grade item in the category is changed.
|
|
|
281 |
if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {
|
|
|
282 |
// The following is named gradeItemRow, but it may also be a row that's representing a grade category.
|
|
|
283 |
// It's ok because it serves as the categories associated grade item in our calculations.
|
|
|
284 |
const gradeItemRow = e.target.closest('tr');
|
|
|
285 |
const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));
|
|
|
286 |
|
|
|
287 |
// This is only required if we are using natural weights.
|
|
|
288 |
if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {
|
|
|
289 |
const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);
|
|
|
290 |
weightElement.value = formatFloat(Math.max(0, parseWeight(weightElement.value)));
|
|
|
291 |
recalculateNaturalWeights(categoryElement);
|
|
|
292 |
}
|
|
|
293 |
}
|
|
|
294 |
});
|
|
|
295 |
|
|
|
296 |
document.addEventListener('submit', e => {
|
|
|
297 |
// If the form is being submitted, then we need to ensure that the weight input fields are all set to
|
|
|
298 |
// a valid value.
|
|
|
299 |
if (e.target.matches('#gradetreeform')) {
|
|
|
300 |
const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');
|
|
|
301 |
if (firstInvalidWeightInput) {
|
|
|
302 |
const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');
|
|
|
303 |
if (firstFocusableInvalidWeightInput) {
|
|
|
304 |
firstFocusableInvalidWeightInput.focus();
|
|
|
305 |
} else {
|
|
|
306 |
firstInvalidWeightInput.scrollIntoView({block: 'center'});
|
|
|
307 |
}
|
|
|
308 |
e.preventDefault();
|
|
|
309 |
}
|
|
|
310 |
}
|
|
|
311 |
});
|
|
|
312 |
};
|