feat: stop calling course blocks rest API and assume LS exists (#803)
- Assume that Learning Sequences is available (waffle has been removed) - Stop calling course blocks API, which provided mostly duplicated information now. - Refactor a bit to avoid needing to globally know which units exist in sequences. That is now provided just-in-time for only the current sequence. - Add /first and /last URLs that you can use instead of unit IDs in URL paths, in service of the above point. AA-1040 AA-1153
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import './courseMetadata.factory';
|
||||
import './sequenceMetadata.factory';
|
||||
import './courseRecommendations.factory';
|
||||
import './learningSequencesOutline.factory';
|
||||
|
||||
@@ -29,6 +29,10 @@ export function buildEmptyOutline(courseId) {
|
||||
|
||||
// Given courseBlocks (output from buildSimpleCourseBlocks), create a matching
|
||||
// Learning Sequences API outline (what the REST API would return to us).
|
||||
// Ideally this method would go away at some point, and we'd use a learning
|
||||
// sequence factory directly. But this method exists as a bridge-gap from
|
||||
// when course blocks were always used anyway. Now that they are rarely used,
|
||||
// this can probably go away.
|
||||
export function buildOutlineFromBlocks(courseBlocks) {
|
||||
const sections = {};
|
||||
const sequences = {};
|
||||
@@ -40,14 +44,14 @@ export function buildOutlineFromBlocks(courseBlocks) {
|
||||
} else if (block.type === 'chapter') {
|
||||
sections[block.id] = {
|
||||
id: block.id,
|
||||
title: block.title,
|
||||
title: block.display_name,
|
||||
start: null,
|
||||
sequence_ids: [...block.children],
|
||||
};
|
||||
} else if (block.type === 'sequential') {
|
||||
sequences[block.id] = {
|
||||
id: block.id,
|
||||
title: block.title,
|
||||
title: block.display_name,
|
||||
accessible: true,
|
||||
start: null,
|
||||
};
|
||||
|
||||
@@ -1,96 +1,8 @@
|
||||
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: {},
|
||||
@@ -108,14 +20,16 @@ export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
models.sequences[seqId] = {
|
||||
id: seqId,
|
||||
title: sequence.title,
|
||||
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/${learningSequencesData.course_key}/jump_to/${seqId}?experience=legacy`,
|
||||
};
|
||||
});
|
||||
|
||||
// Sections
|
||||
learningSequencesData.outline.sections.forEach(section => {
|
||||
// Skipping sections with only inaccessible sequences replicates the behavior of the legacy course blocks API
|
||||
// (But keep it if it was already empty, again to replicate legacy blocks API.)
|
||||
const accessibleSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences);
|
||||
if (accessibleSequenceIds.length === 0) {
|
||||
if (section.sequence_ids.length > 0 && accessibleSequenceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,7 +37,13 @@ export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
sequenceIds: accessibleSequenceIds,
|
||||
courseId: learningSequencesData.course_key,
|
||||
};
|
||||
|
||||
// Add back-references to this section for all child sequences.
|
||||
accessibleSequenceIds.forEach(childSeqId => {
|
||||
models.sequences[childSeqId].sectionId = section.id;
|
||||
});
|
||||
});
|
||||
|
||||
// Course
|
||||
@@ -144,39 +64,24 @@ export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function getCourseBlocks(courseId) {
|
||||
// Do not add further calls to this API - we don't like making use of the modulestore if we can help it
|
||||
export async function getSequenceForUnitDeprecated(courseId, unitId) {
|
||||
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');
|
||||
url.searchParams.append('requested_fields', 'children');
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return normalizeBlocks(courseId, data.blocks);
|
||||
const parent = Object.values(data.blocks).find(block => block.type === 'sequential' && block.children.includes(unitId));
|
||||
return parent?.id;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
const { data } = await getAuthenticatedHttpClient().get(outlineUrl.href, {});
|
||||
return normalizeLearningSequencesData(data);
|
||||
}
|
||||
|
||||
function normalizeTabUrls(id, tabs) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
} from './thunks';
|
||||
export {
|
||||
getResumeBlock,
|
||||
getSequenceForUnitDeprecated,
|
||||
sendActivationEmail,
|
||||
} from './api';
|
||||
export {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Pact, Matchers } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { mergeConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
import {
|
||||
getCourseBlocks,
|
||||
getCourseMetadata,
|
||||
getLearningSequencesOutline,
|
||||
getSequenceMetadata,
|
||||
@@ -33,7 +31,6 @@ const provider = new Pact({
|
||||
});
|
||||
|
||||
describe('Courseware Service', () => {
|
||||
let authenticatedUser;
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
@@ -41,62 +38,11 @@ describe('Courseware Service', () => {
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
authenticatedUser = getAuthenticatedUser();
|
||||
});
|
||||
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
|
||||
describe('When a request to get course blocks is made', () => {
|
||||
it('returns normalized course blocks', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Blocks data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get course blocks',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: '/api/courses/v2/blocks/',
|
||||
query: {
|
||||
course_id: courseId,
|
||||
username: authenticatedUser ? authenticatedUser.username : '',
|
||||
depth: '3',
|
||||
requested_fields: 'children,effort_activities,effort_time,show_gated_sections,graded,special_exam_info,has_scheduled_content',
|
||||
},
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
root: string('block-v1:edX+DemoX+Demo_Course+type@course+block@course'),
|
||||
blocks: like({
|
||||
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
block_id: 'course',
|
||||
lms_web_url: '/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
legacy_web_url: '/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@course+block@course?experience=legacy',
|
||||
student_view_url: '/xblock/block-v1:edX+DemoX+Demo_Course+type@course+block@course',
|
||||
type: 'course',
|
||||
display_name: 'Demonstration Course',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedCourseBlock = {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@course+block@course': {
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
title: 'Demonstration Course',
|
||||
sectionIds: [],
|
||||
hasScheduledContent: false,
|
||||
},
|
||||
};
|
||||
const response = await getCourseBlocks(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response.courses).toEqual(normalizedCourseBlock);
|
||||
expect(response.sections).toEqual({});
|
||||
expect(response.sequences).toEqual({});
|
||||
expect(response.units).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When a request to get a learning sequence outline is made', () => {
|
||||
it('returns a normalized outline', async () => {
|
||||
await provider.addInteraction({
|
||||
@@ -109,7 +55,7 @@ describe('Courseware Service', () => {
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('block-v1:edX+DemoX+Demo_Course'),
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: {
|
||||
sections: [],
|
||||
@@ -120,8 +66,8 @@ describe('Courseware Service', () => {
|
||||
});
|
||||
const normalizedOutline = {
|
||||
courses: {
|
||||
'block-v1:edX+DemoX+Demo_Course': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course',
|
||||
'course-v1:edX+DemoX+Demo_Course': {
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
title: 'Demo Course',
|
||||
sectionIds: [],
|
||||
hasScheduledContent: false,
|
||||
@@ -145,7 +91,7 @@ describe('Courseware Service', () => {
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
course_key: string('block-v1:edX+DemoX+Demo_Course'),
|
||||
course_key: string('course-v1:edX+DemoX+Demo_Course'),
|
||||
title: string('Demo Course'),
|
||||
outline: like({
|
||||
sections: [
|
||||
@@ -191,8 +137,8 @@ describe('Courseware Service', () => {
|
||||
});
|
||||
const normalizedOutline = {
|
||||
courses: {
|
||||
'block-v1:edX+DemoX+Demo_Course': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course',
|
||||
'course-v1:edX+DemoX+Demo_Course': {
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
title: 'Demo Course',
|
||||
sectionIds: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
@@ -204,6 +150,7 @@ describe('Courseware Service', () => {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially accessible',
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
sequenceIds: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
],
|
||||
@@ -213,6 +160,8 @@ describe('Courseware Service', () => {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
title: 'Can access',
|
||||
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible?experience=legacy`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Data layer integration tests', () => {
|
||||
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
const sequenceBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence`;
|
||||
|
||||
@@ -57,7 +56,6 @@ describe('Data layer integration tests', () => {
|
||||
describe('Test fetchCourse', () => {
|
||||
it('Should fail to fetch course and blocks if request error happens', async () => {
|
||||
axiosMock.onGet(courseUrl).networkError();
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).networkError();
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
@@ -87,8 +85,7 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
axiosMock.onGet(forbiddenCourseHomeUrl).reply(200, forbiddenCourseHomeMetadata);
|
||||
axiosMock.onGet(forbiddenCourseUrl).reply(200, forbiddenCourseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, forbiddenCourseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(forbiddenCourseBlocks));
|
||||
|
||||
await executeThunk(thunks.fetchCourse(forbiddenCourseMetadata.id), store.dispatch);
|
||||
|
||||
@@ -103,8 +100,7 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch, normalize, and save metadata', async () => {
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
@@ -124,7 +120,6 @@ describe('Data layer integration tests', () => {
|
||||
// (even though it won't actually filter down in this case).
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, simpleOutline);
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
@@ -148,7 +143,6 @@ describe('Data layer integration tests', () => {
|
||||
// (even though it won't actually filter down in this case).
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
|
||||
// Create an outline with basic matching metadata, but then empty it out...
|
||||
const emptyOutline = buildOutlineFromBlocks(courseBlocks);
|
||||
@@ -201,8 +195,7 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
axiosMock.onGet(sequenceUrl).reply(200, sequenceMetadata);
|
||||
|
||||
// setting course with blocks before sequence to check that blocks receive
|
||||
@@ -217,12 +210,6 @@ describe('Data layer integration tests', () => {
|
||||
activeUnitIndex: expect.any(Number),
|
||||
}),
|
||||
});
|
||||
expect(state.models.units).toEqual({
|
||||
[unitId]: expect.not.objectContaining({
|
||||
complete: null,
|
||||
bookmarked: expect.any(Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
// Update our state variable again.
|
||||
state = store.getState();
|
||||
|
||||
@@ -13,6 +13,7 @@ const slice = createSlice({
|
||||
courseId: null,
|
||||
sequenceStatus: 'loading',
|
||||
sequenceId: null,
|
||||
sequenceMightBeUnit: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
@@ -34,14 +35,17 @@ const slice = createSlice({
|
||||
fetchSequenceRequest: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = LOADING;
|
||||
state.sequenceMightBeUnit = false;
|
||||
},
|
||||
fetchSequenceSuccess: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = LOADED;
|
||||
state.sequenceMightBeUnit = false;
|
||||
},
|
||||
fetchSequenceFailure: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = FAILED;
|
||||
state.sequenceMightBeUnit = payload.sequenceMightBeUnit || false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
getBlockCompletion,
|
||||
getCourseBlocks,
|
||||
getCourseMetadata,
|
||||
getLearningSequencesOutline,
|
||||
getSequenceMetadata,
|
||||
@@ -22,119 +21,15 @@ import {
|
||||
fetchSequenceFailure,
|
||||
} from './slice';
|
||||
|
||||
/**
|
||||
* Combines the models from the Course Blocks and Learning Sequences API into a
|
||||
* new models obj that is returned. Does not mutate the models passed in.
|
||||
*
|
||||
* For performance and long term maintainability, we want to switch as much of
|
||||
* the Courseware MFE to use the Learning Sequences API as possible, and
|
||||
* eventually remove calls to the Course Blocks API. However, right now, certain
|
||||
* data still has to come form the Course Blocks API. This function is a
|
||||
* transitional step to help build out some of the data from the new API, while
|
||||
* falling back to the Course Blocks API for other things.
|
||||
*
|
||||
* Overall performance gains will not be realized until we completely remove
|
||||
* this call to the Course Blocks API (and the need for this function).
|
||||
*
|
||||
* @param {*} learningSequencesModels Normalized model from normalizeLearningSequencesData
|
||||
* @param {*} courseBlocksModels Normalized model from normalizeBlocks
|
||||
*/
|
||||
function mergeLearningSequencesWithCourseBlocks(learningSequencesModels, courseBlocksModels) {
|
||||
// If there's no Learning Sequences API data yet (not active for this course),
|
||||
// send back the course blocks model as-is.
|
||||
if (learningSequencesModels === null) {
|
||||
return courseBlocksModels;
|
||||
}
|
||||
const mergedModels = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
|
||||
// Units are now copied over verbatim from Course Blocks API, but they
|
||||
// should eventually come just-in-time, once the Sequence Metadata API is
|
||||
// made to be acceptably fast.
|
||||
units: courseBlocksModels.units,
|
||||
};
|
||||
|
||||
// Top level course information
|
||||
//
|
||||
// It is not at all clear to me why courses is a dict when there's only ever
|
||||
// one course, but I'm not going to make that model change right now.
|
||||
const lsCourse = Object.values(learningSequencesModels.courses)[0];
|
||||
const [courseBlockId, courseBlock] = Object.entries(courseBlocksModels.courses)[0];
|
||||
|
||||
// The Learning Sequences API never exposes the usage key of the root course
|
||||
// block, which is used as the key here (instead of the CourseKey). It doesn't
|
||||
// look like anything actually queries for this value though, and even the
|
||||
// courseBlocksModels.courses uses the CourseKey as the "id" in the value. So
|
||||
// I'm imitating the form here to minimize the chance of things breaking, but
|
||||
// I think we should just forget the keys and replace courses with a singular
|
||||
// course. I might end up doing that before my refactoring is done here. >_<
|
||||
mergedModels.courses[courseBlockId] = {
|
||||
// Learning Sequences API Data
|
||||
id: lsCourse.id,
|
||||
title: lsCourse.title,
|
||||
sectionIds: lsCourse.sectionIds,
|
||||
hasScheduledContent: lsCourse.hasScheduledContent,
|
||||
|
||||
// Still pulling from Course Blocks API
|
||||
effortActivities: courseBlock.effortActivities,
|
||||
effortTime: courseBlock.effortTime,
|
||||
};
|
||||
|
||||
// List of Sequences comes from Learning Sequences. Course Blocks will have
|
||||
// extra sequences that we don't want to display to the user, like ones that
|
||||
// are empty because all the enclosed units are in user partition groups that
|
||||
// the user is not a part of (e.g. Verified Track).
|
||||
Object.entries(learningSequencesModels.sequences).forEach(([sequenceId, sequence]) => {
|
||||
const blocksSequence = courseBlocksModels.sequences[sequenceId];
|
||||
mergedModels.sequences[sequenceId] = {
|
||||
// Learning Sequences API Data
|
||||
id: sequenceId,
|
||||
title: sequence.title,
|
||||
|
||||
// Still pulling from Course Blocks API Data:
|
||||
effortActivities: blocksSequence.effortActivities,
|
||||
effortTime: blocksSequence.effortTime,
|
||||
legacyWebUrl: blocksSequence.legacyWebUrl,
|
||||
unitIds: blocksSequence.unitIds,
|
||||
};
|
||||
});
|
||||
|
||||
// List of Sections comes from Learning Sequences.
|
||||
Object.entries(learningSequencesModels.sections).forEach(([sectionId, section]) => {
|
||||
const blocksSection = courseBlocksModels.sections[sectionId];
|
||||
mergedModels.sections[sectionId] = {
|
||||
// Learning Sequences API Data
|
||||
id: sectionId,
|
||||
title: section.title,
|
||||
sequenceIds: section.sequenceIds,
|
||||
courseId: lsCourse.id,
|
||||
|
||||
// Still pulling from Course Blocks API Data:
|
||||
effortActivities: blocksSection.effortActivities,
|
||||
effortTime: blocksSection.effortTime,
|
||||
};
|
||||
// Add back-references to this section for all child sequences.
|
||||
section.sequenceIds.forEach(childSeqId => {
|
||||
mergedModels.sequences[childSeqId].sectionId = sectionId;
|
||||
});
|
||||
});
|
||||
|
||||
return mergedModels;
|
||||
}
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseMetadata(courseId),
|
||||
getCourseBlocks(courseId),
|
||||
getLearningSequencesOutline(courseId),
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
]).then(([
|
||||
courseMetadataResult,
|
||||
courseBlocksResult,
|
||||
learningSequencesOutlineResult,
|
||||
courseHomeMetadataResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
@@ -154,15 +49,12 @@ export function fetchCourse(courseId) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseBlocksResult.status === 'fulfilled') {
|
||||
if (learningSequencesOutlineResult.status === 'fulfilled') {
|
||||
const {
|
||||
courses, sections, sequences, units,
|
||||
} = mergeLearningSequencesWithCourseBlocks(
|
||||
learningSequencesOutlineResult.value,
|
||||
courseBlocksResult.value,
|
||||
);
|
||||
courses, sections, sequences,
|
||||
} = learningSequencesOutlineResult.value;
|
||||
|
||||
// This updates the course with a sectionIds array from the blocks data.
|
||||
// This updates the course with a sectionIds array from the Learning Sequence data.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'coursewareMeta',
|
||||
modelsMap: courses,
|
||||
@@ -171,31 +63,27 @@ export function fetchCourse(courseId) {
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences and units because the sequence metadata may have come back first.
|
||||
// We update for sequences because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
modelsMap: units,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled';
|
||||
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
|
||||
const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled';
|
||||
|
||||
// Log errors for each request if needed. Course block failures may occur
|
||||
// Log errors for each request if needed. Outline failures may occur
|
||||
// even if the course metadata request is successful
|
||||
if (!fetchedBlocks) {
|
||||
const { response } = courseBlocksResult.reason;
|
||||
if (!fetchedOutline) {
|
||||
const { response } = learningSequencesOutlineResult.reason;
|
||||
if (response && response.status === 403) {
|
||||
// 403 responses are normal - they happen when the learner is logged out.
|
||||
// We'll redirect them in a moment to the outline tab by calling fetchCourseDenied() below.
|
||||
logInfo(courseBlocksResult.reason);
|
||||
logInfo(learningSequencesOutlineResult.reason);
|
||||
} else {
|
||||
logError(courseBlocksResult.reason);
|
||||
logError(learningSequencesOutlineResult.reason);
|
||||
}
|
||||
}
|
||||
if (!fetchedMetadata) {
|
||||
@@ -205,7 +93,7 @@ export function fetchCourse(courseId) {
|
||||
logError(courseHomeMetadataResult.reason);
|
||||
}
|
||||
if (fetchedMetadata && fetchedCourseHomeMetadata) {
|
||||
if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedBlocks) {
|
||||
if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) {
|
||||
// User has access
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
return;
|
||||
@@ -251,11 +139,11 @@ export function fetchSequence(sequenceId) {
|
||||
// Some errors are expected - for example, CoursewareContainer may request sequence metadata for a unit and rely
|
||||
// on the request failing to notice that it actually does have a unit (mostly so it doesn't have to know anything
|
||||
// about the opaque key structure). In such cases, the backend gives us a 422.
|
||||
const isExpected = error.response && error.response.status === 422;
|
||||
if (!isExpected) {
|
||||
const sequenceMightBeUnit = error?.response?.status === 422;
|
||||
if (!sequenceMightBeUnit) {
|
||||
logError(error);
|
||||
}
|
||||
dispatch(fetchSequenceFailure({ sequenceId }));
|
||||
dispatch(fetchSequenceFailure({ sequenceId, sequenceMightBeUnit }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user