Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
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
};