import { logError, logInfo } from '@edx/frontend-platform/logging'; import { getCourseHomeCourseMetadata } from '../../course-home/data/api'; import { addModel, addModelsMap, updateModel, updateModels, updateModelsMap, } from '../../generic/model-store'; import { getBlockCompletion, getCourseDiscussionConfig, getCourseMetadata, getCourseOutline, getCourseTopics, getCoursewareOutlineSidebarToggles, getLearningSequencesOutline, getSequenceMetadata, postIntegritySignature, postSequencePosition, } from './api'; import { fetchCourseDenied, fetchCourseFailure, fetchCourseRequest, fetchCourseSuccess, fetchSequenceFailure, fetchSequenceRequest, fetchSequenceSuccess, fetchCourseOutlineRequest, fetchCourseOutlineSuccess, fetchCourseOutlineFailure, setCoursewareOutlineSidebarToggles, updateCourseOutlineCompletion, } from './slice'; export function fetchCourse(courseId) { return async (dispatch) => { dispatch(fetchCourseRequest({ courseId })); Promise.allSettled([ getCourseMetadata(courseId), getLearningSequencesOutline(courseId), getCourseHomeCourseMetadata(courseId, 'courseware'), getCoursewareOutlineSidebarToggles(courseId), ]).then(([ courseMetadataResult, learningSequencesOutlineResult, courseHomeMetadataResult, coursewareOutlineSidebarTogglesResult]) => { const fetchedMetadata = courseMetadataResult.status === 'fulfilled'; const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled'; const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled'; const fetchedCoursewareOutlineSidebarTogglesResult = coursewareOutlineSidebarTogglesResult.status === 'fulfilled'; if (fetchedMetadata) { dispatch(addModel({ modelType: 'coursewareMeta', model: courseMetadataResult.value, })); } if (fetchedCourseHomeMetadata) { dispatch(addModel({ modelType: 'courseHomeMeta', model: { id: courseId, ...courseHomeMetadataResult.value, }, })); } if (fetchedOutline) { const { courses, sections, sequences, } = learningSequencesOutlineResult.value; // This updates the course with a sectionIds array from the Learning Sequence data. dispatch(updateModelsMap({ modelType: 'coursewareMeta', modelsMap: courses, })); dispatch(addModelsMap({ modelType: 'sections', modelsMap: sections, })); // We update for sequences because the sequence metadata may have come back first. dispatch(updateModelsMap({ modelType: 'sequences', modelsMap: sequences, })); } if (fetchedCoursewareOutlineSidebarTogglesResult) { const { enable_navigation_sidebar: enableNavigationSidebar, always_open_auxiliary_sidebar: alwaysOpenAuxiliarySidebar, enable_completion_tracking: enableCompletionTracking, } = coursewareOutlineSidebarTogglesResult.value; dispatch(setCoursewareOutlineSidebarToggles( { enableNavigationSidebar, alwaysOpenAuxiliarySidebar, enableCompletionTracking }, )); } // Log errors for each request if needed. Outline failures may occur // even if the course metadata request is successful 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(learningSequencesOutlineResult.reason); } else { logError(learningSequencesOutlineResult.reason); } } if (!fetchedMetadata) { logError(courseMetadataResult.reason); } if (!fetchedCourseHomeMetadata) { logError(courseHomeMetadataResult.reason); } if (!fetchedCoursewareOutlineSidebarTogglesResult) { logError(coursewareOutlineSidebarTogglesResult.reason); } if (fetchedMetadata && fetchedCourseHomeMetadata) { if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) { // User has access dispatch(fetchCourseSuccess({ courseId })); return; } // User either doesn't have access or only has partial access // (can't access course blocks) dispatch(fetchCourseDenied({ courseId })); return; } // Definitely an error happening dispatch(fetchCourseFailure({ courseId })); }); }; } export function fetchSequence(sequenceId, isPreview) { return async (dispatch) => { dispatch(fetchSequenceRequest({ sequenceId })); try { const { sequence, units } = await getSequenceMetadata(sequenceId, { preview: isPreview ? '1' : '0' }); if (sequence.blockType !== 'sequential') { // Some other block types (particularly 'chapter') can be returned // by this API. We want to error in that case, since downstream // courseware code is written to render Sequences of Units. logError( `Requested sequence '${sequenceId}' ` + `has block type '${sequence.blockType}'; expected block type 'sequential'.`, ); dispatch(fetchSequenceFailure({ sequenceId })); } else { dispatch(updateModel({ modelType: 'sequences', model: sequence, })); dispatch(updateModels({ modelType: 'units', models: units, })); dispatch(fetchSequenceSuccess({ sequenceId })); } } catch (error) { // 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 sequenceMightBeUnit = error?.response?.status === 422; if (!sequenceMightBeUnit) { logError(error); } dispatch(fetchSequenceFailure({ sequenceId, sequenceMightBeUnit })); } }; } export function checkBlockCompletion(courseId, sequenceId, unitId) { return async (dispatch, getState) => { const { models } = getState(); if (models.units[unitId]?.complete) { return {}; // do nothing. Things don't get uncompleted after they are completed. } try { const isComplete = await getBlockCompletion(courseId, sequenceId, unitId); dispatch(updateModel({ modelType: 'units', model: { id: unitId, complete: isComplete, }, })); dispatch(updateCourseOutlineCompletion({ sequenceId, unitId, isComplete })); return isComplete; } catch (error) { logError(error); } return {}; }; } export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) { return async (dispatch, getState) => { const { models } = getState(); const initialActiveUnitIndex = models.sequences[sequenceId].activeUnitIndex; // Optimistically update the position. dispatch(updateModel({ modelType: 'sequences', model: { id: sequenceId, activeUnitIndex, }, })); try { await postSequencePosition(courseId, sequenceId, activeUnitIndex); // Update again under the assumption that the above call succeeded, since it doesn't return a // meaningful response. dispatch(updateModel({ modelType: 'sequences', model: { id: sequenceId, activeUnitIndex, }, })); } catch (error) { logError(error); dispatch(updateModel({ modelType: 'sequences', model: { id: sequenceId, activeUnitIndex: initialActiveUnitIndex, }, })); } }; } export function saveIntegritySignature(courseId, isMasquerading) { return async (dispatch) => { try { // If the request is made by a staff user masquerading as a specific learner, // don't actually create a signature for them on the backend, // only the modal dialog will be dismissed if (!isMasquerading) { await postIntegritySignature(courseId); } dispatch(updateModel({ modelType: 'coursewareMeta', model: { id: courseId, userNeedsIntegritySignature: false, }, })); } catch (error) { logError(error); } }; } export function getCourseDiscussionTopics(courseId) { return async (dispatch) => { try { const config = await getCourseDiscussionConfig(courseId); // Only load topics for the openedx provider, the legacy provider uses // the xblock if (config.provider === 'openedx') { const topics = await getCourseTopics(courseId); dispatch(updateModels({ modelType: 'discussionTopics', models: topics.filter(topic => topic.usageKey), idField: 'usageKey', })); } } catch (error) { logError(error); } }; } export function getCourseOutlineStructure(courseId) { return async (dispatch) => { dispatch(fetchCourseOutlineRequest()); try { const courseOutline = await getCourseOutline(courseId); dispatch(fetchCourseOutlineSuccess({ courseOutline })); } catch (error) { logError(error); dispatch(fetchCourseOutlineFailure()); } }; }