Normally, these sequences are skipped. But if the user manually goes to the section, they should be notified why they can't access it. That can easily happen if they bookmarked the page or something. AA-1000
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
|
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
import { logInfo } from '@edx/frontend-platform/logging';
|
|
import { getTimeOffsetMillis } from '../../course-home/data/api';
|
|
import { appendBrowserTimezoneToUrl } from '../../utils';
|
|
|
|
export function normalizeBlocks(courseId, blocks) {
|
|
const models = {
|
|
courses: {},
|
|
sections: {},
|
|
sequences: {},
|
|
units: {},
|
|
};
|
|
|
|
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 || false,
|
|
};
|
|
break;
|
|
case 'chapter':
|
|
models.sections[block.id] = {
|
|
id: block.id,
|
|
title: block.display_name,
|
|
sequenceIds: block.children || [],
|
|
};
|
|
break;
|
|
|
|
case 'sequential':
|
|
models.sequences[block.id] = {
|
|
effortActivities: block.effort_activities,
|
|
effortTime: block.effort_time,
|
|
id: block.id,
|
|
title: block.display_name,
|
|
legacyWebUrl: block.legacy_web_url,
|
|
unitIds: block.children || [],
|
|
};
|
|
break;
|
|
case 'vertical':
|
|
models.units[block.id] = {
|
|
graded: block.graded,
|
|
id: block.id,
|
|
title: block.display_name,
|
|
legacyWebUrl: block.legacy_web_url,
|
|
};
|
|
break;
|
|
default:
|
|
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, sequential, and vertical.`);
|
|
}
|
|
});
|
|
|
|
// 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.`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
Object.values(models.sequences).forEach(sequence => {
|
|
if (Array.isArray(sequence.unitIds)) {
|
|
sequence.unitIds.forEach(unitId => {
|
|
if (unitId in models.units) {
|
|
models.units[unitId].sequenceId = sequence.id;
|
|
} else {
|
|
logInfo(`Sequence ${sequence.id} has child block ${unitId}, but that block is not in the list of units.`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
return models;
|
|
}
|
|
|
|
export function normalizeLearningSequencesData(learningSequencesData) {
|
|
const models = {
|
|
courses: {},
|
|
sections: {},
|
|
sequences: {},
|
|
};
|
|
|
|
// Course
|
|
const now = new Date();
|
|
models.courses[learningSequencesData.course_key] = {
|
|
id: learningSequencesData.course_key,
|
|
title: learningSequencesData.title,
|
|
sectionIds: learningSequencesData.outline.sections.map(section => section.id),
|
|
|
|
// Scan through all the sequences and look for ones that aren't accessible
|
|
// to us yet because the start date has not yet passed. (Some may be
|
|
// inaccessible because the end_date has passed.)
|
|
hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(
|
|
seq => !seq.accessible && now < Date.parse(seq.effective_start),
|
|
),
|
|
};
|
|
|
|
// Sections
|
|
learningSequencesData.outline.sections.forEach(section => {
|
|
models.sections[section.id] = {
|
|
id: section.id,
|
|
title: section.title,
|
|
sequenceIds: section.sequence_ids,
|
|
};
|
|
});
|
|
|
|
// Sequences
|
|
Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => {
|
|
models.sequences[seqId] = {
|
|
id: seqId,
|
|
title: sequence.title,
|
|
};
|
|
});
|
|
|
|
return models;
|
|
}
|
|
|
|
export async function getCourseBlocks(courseId) {
|
|
const authenticatedUser = getAuthenticatedUser();
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
|
|
url.searchParams.append('course_id', courseId);
|
|
url.searchParams.append('username', authenticatedUser ? authenticatedUser.username : '');
|
|
url.searchParams.append('depth', 3);
|
|
url.searchParams.append('requested_fields', 'children,effort_activities,effort_time,show_gated_sections,graded,special_exam_info,has_scheduled_content');
|
|
|
|
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
|
return normalizeBlocks(courseId, data.blocks);
|
|
}
|
|
|
|
// Returns the output of the Learning Sequences API, or null if that API is not
|
|
// currently available for this user in this course.
|
|
export async function getLearningSequencesOutline(courseId) {
|
|
const outlineUrl = new URL(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/${courseId}`);
|
|
|
|
try {
|
|
const { data } = await getAuthenticatedHttpClient().get(outlineUrl.href, {});
|
|
return normalizeLearningSequencesData(data);
|
|
} catch (error) {
|
|
// This is not a critical API to use at the moment. If it errors for any
|
|
// reason, just send back a null so the higher layers know to ignore it.
|
|
if (error.response) {
|
|
if (error.response.status === 403) {
|
|
logInfo('Learning Sequences API not enabled for this user.');
|
|
} else {
|
|
logInfo(`Unexpected error calling Learning Sequences API (${error.response.status}). Ignoring.`);
|
|
}
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeTabUrls(id, tabs) {
|
|
// If api doesn't return the mfe base url, change tab url to point back to LMS
|
|
return tabs.map((tab) => {
|
|
let { url } = tab;
|
|
if (url[0] === '/') {
|
|
url = `${getConfig().LMS_BASE_URL}${tab.url}`;
|
|
}
|
|
return { ...tab, url };
|
|
});
|
|
}
|
|
|
|
function normalizeMetadata(metadata) {
|
|
const requestTime = Date.now();
|
|
const responseTime = requestTime;
|
|
const { data, headers } = metadata;
|
|
return {
|
|
accessExpiration: camelCaseObject(data.access_expiration),
|
|
canShowUpgradeSock: data.can_show_upgrade_sock,
|
|
contentTypeGatingEnabled: data.content_type_gating_enabled,
|
|
id: data.id,
|
|
title: data.name,
|
|
number: data.number,
|
|
offer: camelCaseObject(data.offer),
|
|
org: data.org,
|
|
enrollmentStart: data.enrollment_start,
|
|
enrollmentEnd: data.enrollment_end,
|
|
end: data.end,
|
|
start: data.start,
|
|
enrollmentMode: data.enrollment.mode,
|
|
isEnrolled: data.enrollment.is_active,
|
|
courseAccess: camelCaseObject(data.course_access),
|
|
canViewLegacyCourseware: data.can_view_legacy_courseware,
|
|
originalUserIsStaff: data.original_user_is_staff,
|
|
isStaff: data.is_staff,
|
|
license: data.license,
|
|
verifiedMode: camelCaseObject(data.verified_mode),
|
|
tabs: normalizeTabUrls(data.id, camelCaseObject(data.tabs)),
|
|
userTimezone: data.user_timezone,
|
|
showCalculator: data.show_calculator,
|
|
notes: camelCaseObject(data.notes),
|
|
marketingUrl: data.marketing_url,
|
|
celebrations: camelCaseObject(data.celebrations),
|
|
userHasPassingGrade: data.user_has_passing_grade,
|
|
courseExitPageIsActive: data.course_exit_page_is_active,
|
|
certificateData: camelCaseObject(data.certificate_data),
|
|
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
|
|
verifyIdentityUrl: data.verify_identity_url,
|
|
verificationStatus: data.verification_status,
|
|
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
|
|
relatedPrograms: camelCaseObject(data.related_programs),
|
|
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
|
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
|
|
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
|
|
isMasquerading: data.original_user_is_staff && !data.is_staff,
|
|
};
|
|
}
|
|
|
|
export async function getCourseMetadata(courseId) {
|
|
let url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
|
url = appendBrowserTimezoneToUrl(url);
|
|
const metadata = await getAuthenticatedHttpClient().get(url);
|
|
return normalizeMetadata(metadata);
|
|
}
|
|
|
|
function normalizeSequenceMetadata(sequence) {
|
|
return {
|
|
sequence: {
|
|
id: sequence.item_id,
|
|
blockType: sequence.tag,
|
|
unitIds: sequence.items.map(unit => unit.id),
|
|
bannerText: sequence.banner_text,
|
|
format: sequence.format,
|
|
title: sequence.display_name,
|
|
/*
|
|
Example structure of gated_content when prerequisites exist:
|
|
{
|
|
prereq_id: 'id of the prereq section',
|
|
prereq_url: 'unused by this frontend',
|
|
prereq_section_name: 'Name of the prerequisite section',
|
|
gated: true,
|
|
gated_section_name: 'Name of this gated section',
|
|
*/
|
|
gatedContent: camelCaseObject(sequence.gated_content),
|
|
isTimeLimited: sequence.is_time_limited,
|
|
isProctored: sequence.is_proctored,
|
|
isHiddenAfterDue: sequence.is_hidden_after_due,
|
|
// Position comes back from the server 1-indexed. Adjust here.
|
|
activeUnitIndex: sequence.position ? sequence.position - 1 : 0,
|
|
saveUnitPosition: sequence.save_position,
|
|
showCompletion: sequence.show_completion,
|
|
allowProctoringOptOut: sequence.allow_proctoring_opt_out,
|
|
},
|
|
units: sequence.items.map(unit => ({
|
|
id: unit.id,
|
|
sequenceId: sequence.item_id,
|
|
bookmarked: unit.bookmarked,
|
|
complete: unit.complete,
|
|
title: unit.page_title,
|
|
contentType: unit.type,
|
|
graded: unit.graded,
|
|
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getSequenceMetadata(sequenceId) {
|
|
const { data } = await getAuthenticatedHttpClient()
|
|
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
|
|
|
|
return normalizeSequenceMetadata(data);
|
|
}
|
|
|
|
const getSequenceHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler`;
|
|
|
|
export async function getBlockCompletion(courseId, sequenceId, usageKey) {
|
|
const { data } = await getAuthenticatedHttpClient().post(
|
|
`${getSequenceHandlerUrl(courseId, sequenceId)}/get_completion`,
|
|
{ usage_key: usageKey },
|
|
);
|
|
return data.complete === true;
|
|
}
|
|
|
|
export async function postSequencePosition(courseId, sequenceId, activeUnitIndex) {
|
|
const { data } = await getAuthenticatedHttpClient().post(
|
|
`${getSequenceHandlerUrl(courseId, sequenceId)}/goto_position`,
|
|
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
|
|
{ position: activeUnitIndex + 1 },
|
|
);
|
|
return data;
|
|
}
|
|
|
|
export async function getResumeBlock(courseId) {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`);
|
|
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
|
return camelCaseObject(data);
|
|
}
|
|
|
|
export async function postIntegritySignature(courseId) {
|
|
const { data } = await getAuthenticatedHttpClient().post(
|
|
`${getConfig().LMS_BASE_URL}/api/agreements/v1/integrity_signature/${courseId}`, {},
|
|
);
|
|
return camelCaseObject(data);
|
|
}
|
|
export async function sendActivationEmail() {
|
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/send_account_activation_email`);
|
|
const { data } = await getAuthenticatedHttpClient().post(url.href, {});
|
|
return data;
|
|
}
|