О чем эта статья?
Статья относится к образовательным системам под управлением 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 задача оказалась сложнее, чем она выглядела изначально.