null

Кастомизация расчета прогресса в Open EDX Maple

О чем эта статья?

Статья относится к образовательным системам под управлением Open EDX, и может быть полезна для разработчиков данных систем, а также для тех, кто планирует написание своего движка образовательных платформ.

В статье описывается логика расчета прогресса по курсам, отображаемого слушателям, и возможности ее изменения на примере из моей практики. 

Фото ниже показывает, как выглядит страница прогресса для пользователя:

 

Для начала стоит отметить, что все описанные изменения будут производиться в исходном коде Learning Micro-Frontend, отвечающего за пользовательский интерфейс LMS в версиях EDX начиная с Maple release.

Кастомизация грейдинга на стороне backend'а LMS в данной статье рассматриваться не будет - затрагивается только frontend часть.

 

Кодовая база

Как уже было сказано, работать предстоит с репозиторем Learning Micro-Frontend. Рассмотрим его структуру.

В репозитории за отображение вкладок курса отвечает директория src/course-home. В ней поддиректории содержат компоненты для отображения различных вкладок (даты, форум, прогресс и.т.д).

Все данные, необходимые для отображения этих вкладок, получаются функцией из файла src/course-home/data/api.js. С ним мы и будем работать.

Рассмотрим детально процесс получения данных для страницы прогресса

 

Логика расчета прогресса

1) Вызывается функция getProgressTabData


В нем мы получаем данные о прогрессе слушателя с сервера через http-клиент. Их приблизительный вид:

{
	courseGrade: {
		isPassing: true
		letterGrade: A
		...
	}
	gradingPolicy: {
		assignmentPolicies: [
			{
				type: 'Тест'
				weight: 1
				numDroppable: 0
				...
			}
			...
		]
	}
	sectionScores: [
		{
			displayName: "Раздел"
			subsections: [
				{
					assignmentType: "Тест"
					displayName: "Подраздел"
					numPointsEarned: 1
					numPointsPossible: 1
					...
				}
			]
			...
		}
		...
	]
	...
}

 

Подробнее о том, за что отвечает каждый из параметров на странице прогресса:

 

 

2) Вызывается функция normalizeAssignmentPolicies с полученными данными

 

// заполняем словарь
sectionScores.forEach((chapter) => {
    chapter.subsections.forEach((subsection) => {
      const {
        assignmentType,
        numPointsEarned,
        numPointsPossible,
      } = subsection;
      ...
      gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
      ...
    });
});


В нем данные массива sectionScores, полученного с сервера, преобразуются в словарь вида assignmentType: [gradePercent1, gradePercent2, ... ] для дальнейшего расчета оценок по типам заданий.

То есть каждому типу задания в соответствие ставятся проценты, полученные слушателем за каждое из заданий этого типа.

 

3) Для каждого из типов заданий вызывается метод calculateAssignmentTypeGrades


В методе рассчитывается балл слушателя по каждому типу заданий.

В начале отбрасываются наименьшие 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
	...
}

Решение

Проблему я решал в несколько шагов, последовательно меняя логику функций:

1) Адаптировал логику функции normalizeAssignmentPolicies

В нем вместо процента за задание в словарь передаются все параметры (чтобы позже можно было правильно расчитать взвешенные оценки) в виде объекта.

// заполняем словарь с учетом весов
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,
      });
      ...
    });
});

2) Изменил логику calculateAssignmentTypeGrades

Во-первых, пришлось изменить критерии отброса заданий с наименьшими полученными баллами из-за того, что раньше в метод передавались только проценты за задания, а теперь - объекты.

Также поменялась сама логика расчета оценок по типам заданий: теперь они считались не просто как (сумма процентов / количество заданий), а с учетом весов каждого конкретного задания:

// отбрасываем наименьшие 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 задача оказалась сложнее, чем она выглядела изначально. 

 

Назад