О чем эта статья?
Статья относится к образовательным системам под управлением Open EDX, и может быть полезна для разработчиков данных систем, а также для тех, кто планирует написание своего движка образовательных платформ.
В статье описывается логика расчета прогресса по курсам, отображаемого слушателям, и возможности ее изменения на примере из моей практики.
Фото ниже показывает, как выглядит страница прогресса для пользователя:

Для начала стоит отметить, что все описанные изменения будут производиться в исходном коде Learning Micro-Frontend, отвечающего за пользовательский интерфейс LMS в версиях EDX начиная с Maple release.
Кастомизация грейдинга на стороне backend'а LMS в данной статье рассматриваться не будет - затрагивается только frontend часть.
Кодовая база
Как уже было сказано, работать предстоит с репозиторем Learning Micro-Frontend. Рассмотрим его структуру.
В репозитории за отображение вкладок курса отвечает директория src/course-home. В ней поддиректории содержат компоненты для отображения различных вкладок (даты, форум, прогресс и.т.д).
Все данные, необходимые для отображения этих вкладок, получаются функцией из файла src/course-home/data/api.js. С ним мы и будем работать.
Рассмотрим детально процесс получения данных для страницы прогресса
Логика расчета прогресса
В нем мы получаем данные о прогрессе слушателя с сервера через http-клиент. Их приблизительный вид:
{
courseGrade: {
isPassing: true
letterGrade: A
...
}
gradingPolicy: {
assignmentPolicies: [
{
type: 'Тест'
weight: 1
numDroppable: 0
...
}
...
]
}
sectionScores: [
{
displayName: "Раздел"
subsections: [
{
assignmentType: "Тест"
displayName: "Подраздел"
numPointsEarned: 1
numPointsPossible: 1
...
}
]
...
}
...
]
...
}
Подробнее о том, за что отвечает каждый из параметров на странице прогресса:

// заполняем словарь
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
...
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
...
});
});
В нем данные массива sectionScores, полученного с сервера, преобразуются в словарь вида assignmentType: [gradePercent1, gradePercent2, ... ] для дальнейшего расчета оценок по типам заданий.
То есть каждому типу задания в соответствие ставятся проценты, полученные слушателем за каждое из заданий этого типа.
В методе рассчитывается балл слушателя по каждому типу заданий.
В начале отбрасываются наименьшие numDroppable оценок. Далее общий балл считается как средний процент по заданиям.
// отбрасываем наименьшие dropCount
while (dropCount && points.length >= dropCount) {
const lowestScore = Math.min(...points);
const lowestScoreIndex = points.indexOf(lowestScore);
points.splice(lowestScoreIndex, 1);
dropCount--;
}
// считаем средний процент по заданиям
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
weightedGrade = averageGrade * assignmentWeight;
}
В отображении элементы weightedGrade и assignmentGrade участвуют так:

4) На основе weightnedGrade рассчитывается общий балл за курс
По сумме weightedGrade считается процент слушателя за курс
// суммируем weightedGrade
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
) : camelCasedData.courseGrade.percent;
Пример кастомизации
Для примера приведу кейс, который заставил меня детально разобраться в логике расчета прогресса, описаной выше.
Постановка задачи
Требовалось учитывать атрибут веса (weight) для каждого конкретного задания, а не только для типов заданий. Он передавался с сервера вместе с оценками, то есть оценка, передаваемая с сервера (sectionScores.section) выглядела так:
{
assignmentType: "Тест"
displayName: "Подраздел"
numPointsEarned: 1
numPointsPossible: 1
weight: 2
...
}
Решение
Проблему я решал в несколько шагов, последовательно меняя логику функций:
В нем вместо процента за задание в словарь передаются все параметры (чтобы позже можно было правильно расчитать взвешенные оценки) в виде объекта.
// заполняем словарь с учетом весов
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
const {
assignmentType,
numPointsEarned,
numPointsPossible,
weight,
} = subsection;
...
gradeByAssignmentType[assignmentType].grades.push({
grade: numPointsEarned ? numPointsEarned / numPointsPossible : 0,
earned: numPointsEarned,
possible: numPointsPossible,
weight: weight,
});
...
});
});
Во-первых, пришлось изменить критерии отброса заданий с наименьшими полученными баллами из-за того, что раньше в метод передавались только проценты за задания, а теперь - объекты.
Также поменялась сама логика расчета оценок по типам заданий: теперь они считались не просто как (сумма процентов / количество заданий), а с учетом весов каждого конкретного задания:
// отбрасываем наименьшие dropCount
while (dropCount && points.length >= dropCount) {
let lowestScoreKey;
let lowestScore = 1e9;
for (let i = 0; i < points.length; i++) {
if (points[i].possible === 0) {
lowestScoreKey = i;
break;
} else {
let score = (points[i].earned / points[i].possible) * points[i].weight
if (score < lowestScore) {
lowestScore = score;
lowestScoreKey = i;
}
}
}
points.splice(lowestScoreKey, 1);
dropCount--;
}
// считаем процент за тип заданий с учетом весов
let averageGrade = 0;
let weightSum = 0;
if (points.length) {
points.forEach(point => {
if (!point.weight)
point.weight = 1;
averageGrade += point.possible ? (point.earned / point.possible * point.weight) : 0;
weightSum += point.weight;
});
averageGrade = weightSum ? (averageGrade / weightSum) : 0;
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
Этих небольших изменений хватило для решения моей задачи, однако из-за сложности документации Open EDX задача оказалась сложнее, чем она выглядела изначально.