* refactor: shift grade summary calculation to backend (#1797) Refactors the grade summary logic to delegate all calculation responsibilities to the backend. Previously, the frontend was performing grade summary computations using data fetched from the API. Now, the API itself provides the fully computed grade summary, simplifying the frontend and ensuring consistent results across clients. Additionally, a "Hidden Grades" label has been added in the grade summary table to clearly indicate sections where grades are not visible to learners. Finally, for visibility settings that depend on the due date, this PR adds a banner on the Progress page indicating that grades are not yet released, along with the relevant due date information. * chore: update snapshots --------- Co-authored-by: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com>
404 lines
15 KiB
JavaScript
404 lines
15 KiB
JavaScript
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
import { logInfo } from '@edx/frontend-platform/logging';
|
|
import { appendBrowserTimezoneToUrl } from '../../utils';
|
|
|
|
/**
|
|
* Tweak the metadata for consistency
|
|
* @param metadata the data to normalize
|
|
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
|
* @returns {Object} The normalized metadata
|
|
*/
|
|
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
|
const data = camelCaseObject(metadata);
|
|
return {
|
|
...data,
|
|
tabs: data.tabs.map(tab => ({
|
|
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
|
// If needed, we switch it to "outline" here for
|
|
// use within the MFE to differentiate between course home and courseware.
|
|
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
|
title: tab.title,
|
|
url: tab.url,
|
|
})),
|
|
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
|
};
|
|
}
|
|
|
|
export function normalizeOutlineBlocks(courseId, blocks) {
|
|
const models = {
|
|
courses: {},
|
|
sections: {},
|
|
sequences: {},
|
|
};
|
|
Object.values(blocks).forEach(block => {
|
|
switch (block.type) {
|
|
case 'course':
|
|
models.courses[block.id] = {
|
|
id: courseId,
|
|
title: block.display_name,
|
|
sectionIds: block.children || [],
|
|
hasScheduledContent: block.has_scheduled_content,
|
|
};
|
|
break;
|
|
|
|
case 'chapter':
|
|
models.sections[block.id] = {
|
|
complete: block.complete,
|
|
id: block.id,
|
|
title: block.display_name,
|
|
resumeBlock: block.resume_block,
|
|
sequenceIds: block.children || [],
|
|
hideFromTOC: block.hide_from_toc,
|
|
};
|
|
break;
|
|
|
|
case 'sequential':
|
|
models.sequences[block.id] = {
|
|
complete: block.complete,
|
|
description: block.description,
|
|
due: block.due,
|
|
effortActivities: block.effort_activities,
|
|
effortTime: block.effort_time,
|
|
icon: block.icon,
|
|
id: block.id,
|
|
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
|
|
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
|
showLink: !!block.lms_web_url,
|
|
title: block.display_name,
|
|
hideFromTOC: block.hide_from_toc,
|
|
navigationDisabled: block.navigation_disabled,
|
|
isPreview: block.is_preview,
|
|
};
|
|
break;
|
|
|
|
default:
|
|
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
|
}
|
|
});
|
|
|
|
// Next go through each list and use their child lists to decorate those children with a
|
|
// reference back to their parent.
|
|
Object.values(models.courses).forEach(course => {
|
|
if (Array.isArray(course.sectionIds)) {
|
|
course.sectionIds.forEach(sectionId => {
|
|
const section = models.sections[sectionId];
|
|
section.courseId = course.id;
|
|
});
|
|
}
|
|
});
|
|
|
|
Object.values(models.sections).forEach(section => {
|
|
if (Array.isArray(section.sequenceIds)) {
|
|
section.sequenceIds.forEach(sequenceId => {
|
|
if (sequenceId in models.sequences) {
|
|
models.sequences[sequenceId].sectionId = section.id;
|
|
} else {
|
|
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return models;
|
|
}
|
|
|
|
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
|
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
|
url = appendBrowserTimezoneToUrl(url);
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
|
}
|
|
|
|
// For debugging purposes, you might like to see a fully loaded dates tab.
|
|
// Just uncomment the next few lines and the immediate 'return' in the function below
|
|
// import { Factory } from 'rosie';
|
|
// import './__factories__';
|
|
export async function getDatesTabData(courseId) {
|
|
// return camelCaseObject(Factory.build('datesTabData'));
|
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return camelCaseObject(data);
|
|
} catch (error) {
|
|
const httpErrorStatus = error?.response?.status;
|
|
if (httpErrorStatus === 401) {
|
|
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
|
// courseAccess in the metadata call, so just ignore this status for now.
|
|
return {};
|
|
}
|
|
if (httpErrorStatus === 403) {
|
|
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
|
// info is included in the course metadata request and will be handled there as long as this call returns
|
|
// without an error
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getProgressTabData(courseId, targetUserId) {
|
|
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
|
|
|
|
// If targetUserId is passed in, we will get the progress page data
|
|
// for the user with the provided id, rather than the requesting user.
|
|
if (targetUserId) {
|
|
url += `/${targetUserId}/`;
|
|
}
|
|
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
const camelCasedData = camelCaseObject(data);
|
|
|
|
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
|
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
|
// in order to preserve a course team's desired grade formatting.
|
|
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
|
|
|
|
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
|
|
|
|
camelCasedData.gradesFeatureIsPartiallyLocked = false;
|
|
if (camelCasedData.gradesFeatureIsFullyLocked) {
|
|
camelCasedData.sectionScores.forEach((chapter) => {
|
|
chapter.subsections.forEach((subsection) => {
|
|
// If something is eligible to be gated by content type gating and would show up on the progress page
|
|
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
|
|
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
|
|
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
|
|
// since the learner has access to some (but not all) content that would normally be locked
|
|
if (subsection.learnerHasAccess) {
|
|
camelCasedData.gradesFeatureIsPartiallyLocked = true;
|
|
camelCasedData.gradesFeatureIsFullyLocked = false;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
return camelCasedData;
|
|
} catch (error) {
|
|
const httpErrorStatus = error?.response?.status;
|
|
if (httpErrorStatus === 404) {
|
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
|
return {};
|
|
}
|
|
if (httpErrorStatus === 401) {
|
|
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
|
// courseAccess in the metadata call, so just ignore this status for now.
|
|
return {};
|
|
}
|
|
if (httpErrorStatus === 403) {
|
|
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
|
// info is included in the course metadata request and will be handled there as long as this call returns
|
|
// without an error
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getProctoringInfoData(courseId, username) {
|
|
let url;
|
|
if (!getConfig().EXAMS_BASE_URL) {
|
|
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
|
if (username) {
|
|
url += `&username=${encodeURIComponent(username)}`;
|
|
}
|
|
} else {
|
|
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/course_id/${encodeURIComponent(courseId)}/onboarding`;
|
|
if (username) {
|
|
url += `?username=${encodeURIComponent(username)}`;
|
|
}
|
|
}
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return data;
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getLiveTabIframe(courseId) {
|
|
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return data;
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
|
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
|
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
|
|
|
let timeOffsetMillis = 0;
|
|
if (headerDate !== undefined) {
|
|
const headerTime = Date.parse(headerDate);
|
|
const roundTripMillis = requestTime - responseTime;
|
|
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
|
|
timeOffsetMillis = headerTime - localTime;
|
|
}
|
|
|
|
return timeOffsetMillis;
|
|
}
|
|
|
|
export async function getOutlineTabData(courseId) {
|
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
|
const requestTime = Date.now();
|
|
let tabData;
|
|
try {
|
|
tabData = await getAuthenticatedHttpClient().get(url);
|
|
} catch (error) {
|
|
const httpErrorStatus = error?.response?.status;
|
|
if (httpErrorStatus === 403) {
|
|
// The backend sends this if there is a course access error and the user should be redirected. The redirect
|
|
// info is included in the course metadata request and will be handled there as long as this call returns
|
|
// without an error
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const responseTime = Date.now();
|
|
|
|
const {
|
|
data,
|
|
headers,
|
|
} = tabData;
|
|
|
|
const accessExpiration = camelCaseObject(data.access_expiration);
|
|
const certData = camelCaseObject(data.cert_data);
|
|
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
|
const courseGoals = camelCaseObject(data.course_goals);
|
|
const courseTools = camelCaseObject(data.course_tools);
|
|
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
|
const datesWidget = camelCaseObject(data.dates_widget);
|
|
const enableProctoredExams = data.enable_proctored_exams;
|
|
const enrollAlert = camelCaseObject(data.enroll_alert);
|
|
const enrollmentMode = data.enrollment_mode;
|
|
const handoutsHtml = data.handouts_html;
|
|
const hasScheduledContent = data.has_scheduled_content;
|
|
const hasEnded = data.has_ended;
|
|
const offer = camelCaseObject(data.offer);
|
|
const resumeCourse = camelCaseObject(data.resume_course);
|
|
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
|
const userHasPassingGrade = data.user_has_passing_grade;
|
|
const verifiedMode = camelCaseObject(data.verified_mode);
|
|
const welcomeMessageHtml = data.welcome_message_html || '';
|
|
|
|
return {
|
|
accessExpiration,
|
|
certData,
|
|
courseBlocks,
|
|
courseGoals,
|
|
courseTools,
|
|
datesBannerInfo,
|
|
datesWidget,
|
|
enrollAlert,
|
|
enrollmentMode,
|
|
enableProctoredExams,
|
|
handoutsHtml,
|
|
hasScheduledContent,
|
|
hasEnded,
|
|
offer,
|
|
resumeCourse,
|
|
timeOffsetMillis, // This should move to a global time correction reference
|
|
userHasPassingGrade,
|
|
verifiedMode,
|
|
welcomeMessageHtml,
|
|
};
|
|
}
|
|
|
|
export async function postCourseDeadlines(courseId, model) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
|
return getAuthenticatedHttpClient().post(url.href, {
|
|
course_key: courseId,
|
|
research_event_data: { location: `${model}-tab` },
|
|
});
|
|
}
|
|
|
|
export async function deprecatedPostCourseGoals(courseId, goalKey) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
|
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
|
|
}
|
|
|
|
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
|
return getAuthenticatedHttpClient().post(url.href, {
|
|
course_id: courseId,
|
|
days_per_week: daysPerWeek,
|
|
subscribed_to_reminders: subscribedToReminders,
|
|
});
|
|
}
|
|
|
|
export async function postDismissWelcomeMessage(courseId) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
|
|
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
|
}
|
|
|
|
export async function postRequestCert(courseId) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/generate_user_cert`);
|
|
await getAuthenticatedHttpClient().post(url.href);
|
|
}
|
|
|
|
export async function executePostFromPostEvent(postData, researchEventData) {
|
|
const url = new URL(postData.url);
|
|
return getAuthenticatedHttpClient().post(url.href, {
|
|
course_key: postData.bodyParams.courseId,
|
|
research_event_data: researchEventData,
|
|
});
|
|
}
|
|
|
|
export async function unsubscribeFromCourseGoal(token) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
|
|
return getAuthenticatedHttpClient().post(url.href)
|
|
.then(res => camelCaseObject(res));
|
|
}
|
|
|
|
export async function getCoursewareSearchEnabled(courseId) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
|
|
const { data } = await getAuthenticatedHttpClient().get(url.href);
|
|
return { enabled: data.enabled || false };
|
|
}
|
|
|
|
export async function searchCourseContentFromAPI(courseId, searchKeyword, options = {}) {
|
|
const defaults = { page: 0, limit: 20 };
|
|
const { page, limit } = { ...defaults, ...options };
|
|
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/search/${courseId}`);
|
|
const formData = `search_string=${searchKeyword}&page_size=${limit}&page_index=${page}`;
|
|
const response = await getAuthenticatedHttpClient().post(url.href, formData);
|
|
|
|
return camelCaseObject(response);
|
|
}
|
|
|
|
export async function getExamsData(courseId, sequenceId) {
|
|
let url;
|
|
|
|
if (!getConfig().EXAMS_BASE_URL) {
|
|
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
|
} else {
|
|
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
|
}
|
|
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
return camelCaseObject(data);
|
|
} catch (error) {
|
|
const { httpErrorStatus } = error && error.customAttributes;
|
|
if (httpErrorStatus === 404) {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|