import type { ILanguageDefinition } from '@sourceacademy/language-directory/dist/types'; import { camelCase, mapKeys } from 'lodash-es'; import type { SagaIterator } from 'redux-saga'; import { all, call, fork, put, select } from 'redux-saga/effects'; import AcademyActions from 'src/features/academy/AcademyActions'; import type { UsernameRoleGroup } from 'src/features/adminPanel/subcomponents/AddUserPanel'; import { selectConductorEnable } from 'src/features/conductor/flagConductorEnable'; import DashboardActions from 'src/features/dashboard/DashboardActions'; import { deriveLanguageFromChapter } from 'src/features/directory/chapterLanguage'; import LanguageDirectoryActions from 'src/features/directory/LanguageDirectoryActions'; import GroundControlActions from 'src/features/groundControl/GroundControlActions'; import type { GradingSummary } from '../../features/dashboard/DashboardTypes'; import { type GradingOverview, type GradingOverviews, type GradingQuery, type GradingQuestion, SortStates, } from '../../features/grading/GradingTypes'; import type { TeamFormationOverview } from '../../features/teamFormation/TeamFormationTypes'; import SessionActions from '../application/actions/SessionActions'; import { type OverallState, Role } from '../application/ApplicationTypes'; import type { RouterState } from '../application/types/CommonsTypes'; import type { AdminPanelCourseRegistration, CourseConfiguration, CourseRegistration, Tokens, UpdateCourseConfiguration, User, } from '../application/types/SessionTypes'; import { type Assessment, type AssessmentConfiguration, type AssessmentOverview, AssessmentStatuses, ProgressStatuses, type Question, } from '../assessment/AssessmentTypes'; import type { Notification, NotificationFilterFunction, } from '../notificationBadge/NotificationBadgeTypes'; import { combineSagaHandlers } from '../redux/utils'; import { actions } from '../utils/ActionsHelper'; import { computeFrontendRedirectUri, getClientId, getDefaultProvider } from '../utils/AuthHelper'; import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; import WorkspaceActions from '../workspace/WorkspaceActions'; import type { WorkspaceLocation } from '../workspace/WorkspaceTypes'; import { checkAnswerLastModifiedAt, deleteAssessment, deleteTeam, getAssessment, getAssessmentConfigs, getAssessmentOverviews, getCourseConfig, getGrading, getGradingOverviews, getGradingSummary, getLatestCourseRegistrationAndConfiguration, getNotifications, getStudents, getTeamFormationOverview, getTeamFormationOverviews, getTotalXp, getUser, getUserCourseRegistrations, handleResponseError, postAcknowledgeNotifications, postAnswer, postAssessment, postAuth, postCreateCourse, postGrading, postReautogradeAnswer, postReautogradeSubmission, postTeams, postUnsubmit, postUploadTeams, publishGrading, publishGradingAll, putAssessmentConfigs, putCourseConfig, putCourseResearchAgreement, putLatestViewedCourse, putNewUsers, putTeams, putUserRole, removeAssessmentConfig, removeUserCourseRegistration, unpublishGrading, unpublishGradingAll, updateAssessment, uploadAssessment, } from './RequestsSaga'; import { safeTakeEvery as takeEvery } from './SafeEffects'; export function selectTokens() { return select((state: OverallState) => ({ accessToken: state.session.accessToken, refreshToken: state.session.refreshToken, })); } function selectRouter() { return select((state: OverallState) => state.router); } export function* routerNavigate(path: string) { const router: RouterState = yield selectRouter(); return router?.navigate(path); } /** * Assessment workspaces don't expose a language dropdown of their own — they share the same * global languageDirectory Redux slice the Playground dropdown writes to. Setting it here on * assessment load is what makes the assessment's Run/testcase-grading use the right language * instead of whatever was last selected in the Playground. * * The language is derived from the assessment's own chapter (the first question's * library.chapter — assessments don't mix chapters across their own questions), which is a * 1-based index into the language directory's array once Conductor is enabled (see * chapterLanguage.ts) - not a separate assessment-level field. */ function* selectAssessmentLanguageSaga(assessment: Assessment) { const conductorEnabled: boolean = yield select(selectConductorEnable); if (!conductorEnabled) { return; } const languages: ILanguageDefinition[] = yield select( (state: OverallState) => state.languageDirectory.languages, ); const derived = deriveLanguageFromChapter(languages, assessment.questions[0]?.library.chapter); if (!derived) { // Otherwise a prior assessment's selection would leak into this one. yield put(LanguageDirectoryActions.clearSelectedLanguage()); return; } yield put(LanguageDirectoryActions.setSelectedLanguage(derived.languageId)); yield put(LanguageDirectoryActions.setSelectedEvaluator(derived.evaluatorId)); } const newBackendSagaOne = combineSagaHandlers({ [SessionActions.fetchAuth.type]: function* (action): any { const { code, providerId: payloadProviderId } = action.payload; const isVscode: boolean = yield select((state: OverallState) => state.vscode.isVscode); const providerId = payloadProviderId || (getDefaultProvider() || [null])[0]; if (!providerId) { yield call( showWarningMessage, 'Could not log in; invalid provider or no providers configured.', ); return yield routerNavigate('/'); } const clientId = getClientId(providerId); const redirectUrl = computeFrontendRedirectUri(providerId, isVscode); const tokens: Tokens | null = yield call(postAuth, code, providerId, clientId, redirectUrl); if (!tokens) { return yield routerNavigate('/'); } yield put(actions.setTokens(tokens)); yield put(actions.fetchUserAndCourse()); /** * NOTE: Navigation logic is now handled in component. * - Due to route hoisting in react-router v6, which requires us to declare routes at the top level, * we need to rerender the router to include Academy routes when the user logs in, which occurs in ApplicationWrapper.tsx. * - However, the current router instance we have access to HERE in this saga via `routerNavigate` is the old router instance. * - Thus handling navigation in allows us to directly access the latest router via `useNavigate`. */ }, [SessionActions.handleSamlRedirect.type]: function* (action) { const { jwtCookie } = action.payload; const tokens = mapKeys(JSON.parse(jwtCookie), (v, k) => camelCase(k)) as Tokens; yield put(actions.setTokens(tokens)); yield put(actions.fetchUserAndCourse()); }, [SessionActions.fetchUserAndCourse.type]: function* (action) { const tokens: Tokens = yield selectTokens(); // Note: courseRegistration, courseConfiguration and assessmentConfigurations // are either all null OR all not null const { user, courseRegistration, courseConfiguration, assessmentConfigurations, }: { user: User | null; courseRegistration: CourseRegistration | null; courseConfiguration: CourseConfiguration | null; assessmentConfigurations: AssessmentConfiguration[] | null; } = yield call(getUser, tokens); if (!user) { return; } yield put(actions.setUser(user)); // Handle case where user does not have a latest viewed course in the backend // but is enrolled in some course (this happens occationally due to e.g. removal from a course) if (courseConfiguration === null && user.courses.length > 0) { yield put(actions.updateLatestViewedCourse(user.courses[0].courseId)); } if (courseRegistration && courseConfiguration && assessmentConfigurations) { yield put(actions.setCourseRegistration(courseRegistration)); yield put(actions.setCourseConfiguration(courseConfiguration)); yield put(actions.setAssessmentConfigurations(assessmentConfigurations)); } }, [SessionActions.fetchCourseConfig.type]: function* () { const tokens: Tokens = yield selectTokens(); const { config }: { config: CourseConfiguration | null } = yield call(getCourseConfig, tokens); if (config) { yield put(actions.setCourseConfiguration(config)); } }, [SessionActions.fetchAssessmentOverviews.type]: function* () { const tokens: Tokens = yield selectTokens(); const assessmentOverviews: AssessmentOverview[] | null = yield call( getAssessmentOverviews, tokens, ); if (assessmentOverviews) { yield put(actions.updateAssessmentOverviews(assessmentOverviews)); } }, [SessionActions.fetchTotalXp.type]: function* () { const tokens: Tokens = yield selectTokens(); const res: { totalXp: number } = yield call(getTotalXp, tokens); if (res) { yield put(actions.updateTotalXp(res.totalXp)); } }, [SessionActions.fetchTotalXpAdmin.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const courseRegId = action.payload; const res: { totalXp: number } = yield call(getTotalXp, tokens, courseRegId); if (res) { yield put(actions.updateTotalXp(res.totalXp)); } }, [SessionActions.fetchAssessment.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { assessmentId, assessmentPassword } = action.payload; const assessment: Assessment | null = yield call( getAssessment, assessmentId, tokens, undefined, assessmentPassword, ); if (assessment) { yield put(actions.updateAssessment(assessment)); yield call(selectAssessmentLanguageSaga, assessment); } }, [SessionActions.fetchAssessmentAdmin.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { assessmentId, courseRegId } = action.payload; const assessment: Assessment | null = yield call( getAssessment, assessmentId, tokens, courseRegId, ); if (assessment) { yield put(actions.updateAssessment(assessment)); yield call(selectAssessmentLanguageSaga, assessment); } }, [SessionActions.submitAnswer.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const questionId = action.payload.id; const answer = action.payload.answer; const resp: Response | null = yield call(postAnswer, questionId, answer, tokens); if (!resp || !resp.ok) { yield put(WorkspaceActions.updateSaveStatus('assessment', 'saveFailed')); return yield handleResponseError(resp); } // Now, update the answer for the question in the assessment in the store const assessmentId: number = yield select( (state: OverallState) => state.workspaces.assessment.currentAssessment!, ); const assessment: any = yield select( (state: OverallState) => state.session.assessments[assessmentId], ); const newQuestions = assessment.questions.slice().map((question: Question) => { if (question.id === questionId) { return { ...question, answer }; } return question; }); const newAssessment = { ...assessment, questions: newQuestions, }; yield put(actions.updateAssessment(newAssessment)); yield put(actions.updateHasUnsavedChanges('assessment' as WorkspaceLocation, false)); return yield put(WorkspaceActions.updateSaveStatus('assessment', 'saved')); }, [SessionActions.checkAnswerLastModifiedAt.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const questionId = action.payload.id; const lastModifiedAt = action.payload.lastModifiedAt; const saveAnswer = action.payload.saveAnswer; const resp: boolean | null = yield call( checkAnswerLastModifiedAt, questionId, lastModifiedAt, tokens, ); saveAnswer(resp); }, [SessionActions.submitAssessment.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const assessmentId = action.payload; const resp: Response | null = yield call(postAssessment, assessmentId, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield call(showSuccessMessage, 'Submitted!', 2000); // Now, update the status of the assessment overview in the store const overviews: AssessmentOverview[] = yield select( (state: OverallState) => state.session.assessmentOverviews, ); const newOverviews = overviews.map(overview => { if (overview.id === assessmentId) { return { ...overview, status: AssessmentStatuses.submitted }; } return overview; }); return yield put(actions.updateAssessmentOverviews(newOverviews)); }, [SessionActions.fetchGradingOverviews.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const role: Role = yield select((state: OverallState) => state.session.role!); if (role === Role.Student) { return; } const { filterToGroup, publishedFilter, pageParams, filterParams, allColsSortStates } = action.payload; const sortedBy = { sortBy: allColsSortStates.sortBy, sortDirection: '', }; Object.entries(allColsSortStates.currentState).forEach(([key, value]) => { if (allColsSortStates.sortBy === key && key !== '') { if (value !== SortStates.NONE) { sortedBy.sortDirection = value; } else { sortedBy.sortBy = ''; sortedBy.sortDirection = ''; } } }); yield put(actions.increaseRequestCounter()); try { const gradingOverviews: GradingOverviews | null = yield call( getGradingOverviews, tokens, filterToGroup, publishedFilter, pageParams, filterParams, sortedBy, ); if (gradingOverviews) { yield put(actions.updateGradingOverviews(gradingOverviews)); } } finally { yield put(actions.decreaseRequestCounter()); } }, [SessionActions.fetchTeamFormationOverview.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { assessmentId } = action.payload; const teamFormationOverview: TeamFormationOverview | null = yield call( getTeamFormationOverview, assessmentId, tokens, ); if (teamFormationOverview) { yield put(actions.updateTeamFormationOverview(teamFormationOverview)); } }, [SessionActions.fetchTeamFormationOverviews.type]: function* () { const tokens: Tokens = yield selectTokens(); const role: Role = yield select((state: OverallState) => state.session.role!); if (role === Role.Student) { return; } const teamFormationOverviews: TeamFormationOverview[] | null = yield call( getTeamFormationOverviews, tokens, ); if (teamFormationOverviews) { yield put(actions.updateTeamFormationOverviews(teamFormationOverviews)); } }, [SessionActions.fetchStudents.type]: function* () { const tokens: Tokens = yield selectTokens(); const role: Role = yield select((state: OverallState) => state.session.role!); if (role === Role.Student) { return; } const students: User[] | null = yield call(getStudents, tokens); if (students) { yield put(actions.updateStudents(students)); } }, [SessionActions.createTeam.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { assessment, teams } = action.payload; const resp: Response | null = yield call(postTeams, assessment.id, teams, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const teamFormationOverviews: TeamFormationOverview[] | null = yield call( getTeamFormationOverviews, tokens, ); if (teamFormationOverviews) { yield put(actions.updateTeamFormationOverviews(teamFormationOverviews)); } yield call(showSuccessMessage, 'Team created successfully', 1000); if (resp && resp.status === 409) { return yield call(showWarningMessage, resp.statusText); } }, [SessionActions.bulkUploadTeam.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { assessment, file, students } = action.payload; const resp: Response | null = yield call( postUploadTeams, assessment.id, file, students, tokens, ); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const teamFormationOverviews: TeamFormationOverview[] | null = yield call( getTeamFormationOverviews, tokens, ); yield call(showSuccessMessage, 'Team created successfully', 1000); if (teamFormationOverviews) { yield put(actions.updateTeamFormationOverviews(teamFormationOverviews)); } }, [SessionActions.updateTeam.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { teamId, assessment, teams } = action.payload; const resp: Response | null = yield call(putTeams, assessment.id, teamId, teams, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const teamFormationOverviews: TeamFormationOverview[] | null = yield call( getTeamFormationOverviews, tokens, ); yield call(showSuccessMessage, 'Team updated successfully', 1000); if (teamFormationOverviews) { yield put(actions.updateTeamFormationOverviews(teamFormationOverviews)); } }, [SessionActions.deleteTeam.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { teamId } = action.payload; const resp: Response | null = yield call(deleteTeam, teamId, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const teamFormationOverviews: TeamFormationOverview[] | null = yield call( getTeamFormationOverviews, tokens, ); yield call(showSuccessMessage, 'Team deleted successfully', 1000); if (teamFormationOverviews) { yield put(actions.updateTeamFormationOverviews(teamFormationOverviews)); } }, [SessionActions.fetchGrading.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const id = action.payload; const grading: GradingQuery | null = yield call(getGrading, id, tokens); if (grading) { yield put(actions.updateGrading(id, grading)); } }, /** * Unsubmits the submission and updates the grading overviews of the new status. */ [SessionActions.unsubmitSubmission.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { submissionId } = action.payload; const resp: Response | null = yield postUnsubmit(submissionId, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const overviews: GradingOverview[] = yield select( (state: OverallState) => state.session.gradingOverviews?.data || [], ); const newOverviews = overviews.map(overview => { if (overview.submissionId === submissionId) { return { ...overview, progress: ProgressStatuses.attempted }; } return overview; }); const totalPossibleEntries = yield select( (state: OverallState) => state.session.gradingOverviews?.count, ); yield call(showSuccessMessage, 'Unsubmit successful', 1000); yield put(actions.updateGradingOverviews({ count: totalPossibleEntries, data: newOverviews })); }, [SessionActions.publishGrading.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { submissionId } = action.payload; const resp: Response | null = yield publishGrading(submissionId, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const overviews: GradingOverview[] = yield select( (state: OverallState) => state.session.gradingOverviews?.data || [], ); const newOverviews = overviews.map(overview => { if (overview.submissionId === submissionId) { return { ...overview, progress: ProgressStatuses.published }; } return overview; }); const totalPossibleEntries = yield select( (state: OverallState) => state.session.gradingOverviews?.count, ); yield call(showSuccessMessage, 'Publish grading successful', 1000); yield put(actions.updateGradingOverviews({ count: totalPossibleEntries, data: newOverviews })); }, [SessionActions.unpublishGrading.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { submissionId } = action.payload; const resp: Response | null = yield unpublishGrading(submissionId, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const overviews: GradingOverview[] = yield select( (state: OverallState) => state.session.gradingOverviews?.data || [], ); const newOverviews = overviews.map(overview => { if (overview.submissionId === submissionId) { return { ...overview, progress: ProgressStatuses.graded }; } return overview; }); const totalPossibleEntries = yield select( (state: OverallState) => state.session.gradingOverviews?.count, ); yield call(showSuccessMessage, 'Unpublish grading successful', 1000); yield put(actions.updateGradingOverviews({ count: totalPossibleEntries, data: newOverviews })); }, [SessionActions.submitGrading.type]: sendGrade, [SessionActions.submitGradingAndContinue.type]: sendGradeAndContinue, }); function* sendGrade( action: | ReturnType | ReturnType, ): any { const role: Role = yield select((state: OverallState) => state.session.role!); if (role === Role.Student) { return yield call(showWarningMessage, 'Only staff can submit answers.'); } const { submissionId, questionId, xpAdjustment, comments } = action.payload; const tokens: Tokens = yield selectTokens(); const resp: Response | null = yield postGrading( submissionId, questionId, xpAdjustment, tokens, comments, ); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield call(showSuccessMessage, 'Submitted!', 1000); // Now, update the grade for the question in the Grading in the store const grading: GradingQuery = yield select( (state: OverallState) => state.session.gradings[submissionId], ); const newGrading = grading.answers.slice().map((gradingQuestion: GradingQuestion) => { if (gradingQuestion.question.id === questionId) { gradingQuestion.grade = { xpAdjustment, xp: gradingQuestion.grade.xp, comments, gradedAt: new Date().toISOString(), }; } return gradingQuestion; }); yield put( actions.updateGrading(submissionId, { answers: newGrading, assessment: grading.assessment, enable_llm_grading: false, }), ); } function* sendGradeAndContinue(action: ReturnType) { yield* sendGrade(action); const { submissionId } = action.payload; const [currentQuestion, courseId]: [number | undefined, number] = yield select( (state: OverallState) => [state.workspaces.grading.currentQuestion, state.session.courseId!], ); /** * Move to next question for grading: this only works because the * SUBMIT_GRADING_AND_CONTINUE Redux action is currently only * used in the Grading Workspace * * If the questionId is out of bounds, the componentDidUpdate callback of * GradingWorkspace will cause a redirect back to '/academy/grading' */ yield routerNavigate( `/courses/${courseId}/grading/${submissionId}/${(currentQuestion || 0) + 1}`, ); } const newBackendSagaTwo = combineSagaHandlers({ [SessionActions.reautogradeSubmission.type]: function* (action) { const submissionId = action.payload; const tokens: Tokens = yield selectTokens(); const resp: Response | null = yield call(postReautogradeSubmission, submissionId, tokens); yield call(handleReautogradeResponse, resp); }, [SessionActions.reautogradeAnswer.type]: function* (action) { const { submissionId, questionId } = action.payload; const tokens: Tokens = yield selectTokens(); const resp: Response | null = yield call( postReautogradeAnswer, submissionId, questionId, tokens, ); yield call(handleReautogradeResponse, resp); }, [SessionActions.fetchNotifications.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const notifications: Notification[] = yield call(getNotifications, tokens); yield put(actions.updateNotifications(notifications)); }, [SessionActions.acknowledgeNotifications.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const notificationFilter: NotificationFilterFunction | undefined = action.payload.withFilter; const notifications: Notification[] = yield select( (state: OverallState) => state.session.notifications, ); let notificationsToAcknowledge = notifications; if (notificationFilter) { notificationsToAcknowledge = notificationFilter(notifications); } if (notificationsToAcknowledge.length === 0) { return; } const ids = notificationsToAcknowledge.map(n => n.id); const newNotifications: Notification[] = notifications.filter( notification => !ids.includes(notification.id), ); yield put(actions.updateNotifications(newNotifications)); const resp: Response | null = yield call(postAcknowledgeNotifications, tokens, ids); if (!resp || !resp.ok) { return yield handleResponseError(resp); } }, [WorkspaceActions.changeSublanguage.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { sublang } = action.payload; const resp: Response | null = yield call(putCourseConfig, tokens, { sourceChapter: sublang.chapter, sourceVariant: sublang.variant, }); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put( actions.setCourseConfiguration({ sourceChapter: sublang.chapter, sourceVariant: sublang.variant, }), ); yield call(showSuccessMessage, 'Updated successfully!', 1000); }, [SessionActions.updateLatestViewedCourse.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { courseId } = action.payload; const resp: Response | null = yield call(putLatestViewedCourse, tokens, courseId); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const { courseRegistration, courseConfiguration, assessmentConfigurations, }: { courseRegistration: CourseRegistration | null; courseConfiguration: CourseConfiguration | null; assessmentConfigurations: AssessmentConfiguration[] | null; } = yield call(getLatestCourseRegistrationAndConfiguration, tokens); if (!courseRegistration || !courseConfiguration || !assessmentConfigurations) { yield call(showWarningMessage, `Failed to load course!`); return yield routerNavigate('/welcome'); } yield put(actions.setCourseConfiguration(courseConfiguration)); yield put(actions.setAssessmentConfigurations(assessmentConfigurations)); yield put(actions.setCourseRegistration(courseRegistration)); yield call(showSuccessMessage, `Switched to ${courseConfiguration.courseName}!`, 5000); }, [SessionActions.updateCourseConfig.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const courseConfig: UpdateCourseConfiguration = action.payload; const resp: Response | null = yield call(putCourseConfig, tokens, courseConfig); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.setCourseConfiguration(courseConfig)); yield call(showSuccessMessage, 'Updated successfully!', 1000); }, [SessionActions.fetchAssessmentConfigs.type]: function* () { const tokens: Tokens = yield selectTokens(); const assessmentConfigs: AssessmentConfiguration[] | null = yield call( getAssessmentConfigs, tokens, ); if (assessmentConfigs) { yield put(actions.setAssessmentConfigurations(assessmentConfigs)); } }, [SessionActions.updateAssessmentConfigs.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const assessmentConfigs: AssessmentConfiguration[] = action.payload; const resp: Response | null = yield call(putAssessmentConfigs, tokens, assessmentConfigs); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const updatedAssessmentConfigs: AssessmentConfiguration[] | null = yield call( getAssessmentConfigs, tokens, ); if (updatedAssessmentConfigs) { yield put(actions.setAssessmentConfigurations(updatedAssessmentConfigs)); } yield call(showSuccessMessage, 'Updated successfully!', 1000); }, [SessionActions.deleteAssessmentConfig.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const assessmentConfig: AssessmentConfiguration = action.payload; const resp: Response | null = yield call(removeAssessmentConfig, tokens, assessmentConfig); if (!resp || !resp.ok) { return yield handleResponseError(resp); } }, [SessionActions.fetchAdminPanelCourseRegistrations.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const courseRegistrations: AdminPanelCourseRegistration[] | null = yield call( getUserCourseRegistrations, tokens, ); if (courseRegistrations) { yield put(actions.setAdminPanelCourseRegistrations(courseRegistrations)); } }, [AcademyActions.createCourse.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const courseConfig: UpdateCourseConfiguration = action.payload; const resp: Response | null = yield call(postCreateCourse, tokens, courseConfig); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const { user, courseRegistration, courseConfiguration, }: { user: User | null; courseRegistration: CourseRegistration | null; courseConfiguration: CourseConfiguration | null; assessmentConfigurations: AssessmentConfiguration[] | null; } = yield call(getUser, tokens); if (!user || !courseRegistration || !courseConfiguration) { return yield showWarningMessage('An error occurred. Please try again.'); } /** * setUser updates the Dropdown course selection menu with the updated courses * * Setting the role here handles an edge case where a user creates his first ever course. * Without it, the history.push below would not work as the /courses routes will not be rendered * (see Application.tsx) */ yield put(actions.setUser(user)); yield put(actions.setCourseRegistration({ role: Role.Student })); const placeholderAssessmentConfig: AssessmentConfiguration[] = [ { type: 'Missions', assessmentConfigId: -1, isManuallyGraded: true, isGradingAutoPublished: false, displayInDashboard: true, isMinigame: false, hoursBeforeEarlyXpDecay: 0, hasTokenCounter: false, hasVotingFeatures: false, earlySubmissionXp: 0, isAutosaveEnabled: true, }, ]; const resp1: Response | null = yield call( putAssessmentConfigs, tokens, placeholderAssessmentConfig, courseRegistration.courseId, ); if (!resp1 || !resp1.ok) { return yield handleResponseError(resp); } yield call(showSuccessMessage, 'Successfully created your new course!'); yield routerNavigate(`/courses/${courseRegistration.courseId}`); }, [AcademyActions.addNewUsersToCourse.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { users, provider }: { users: UsernameRoleGroup[]; provider: string } = action.payload; const resp: Response | null = yield call(putNewUsers, tokens, users, provider); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAdminPanelCourseRegistrations()); yield call(showSuccessMessage, 'Users added!'); }, [SessionActions.updateCourseResearchAgreement.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { agreedToResearch } = action.payload; const resp: Response | null = yield call(putCourseResearchAgreement, tokens, agreedToResearch); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.setCourseRegistration({ agreedToResearch })); yield call(showSuccessMessage, 'Research preference saved!'); }, [SessionActions.updateUserRole.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { courseRegId, role }: { courseRegId: number; role: Role } = action.payload; const resp: Response | null = yield call(putUserRole, tokens, courseRegId, role); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAdminPanelCourseRegistrations()); yield call(showSuccessMessage, 'Role updated!'); }, [SessionActions.deleteUserCourseRegistration.type]: function* (action) { const tokens: Tokens = yield selectTokens(); const { courseRegId }: { courseRegId: number } = action.payload; const resp: Response | null = yield call(removeUserCourseRegistration, tokens, courseRegId); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAdminPanelCourseRegistrations()); yield call(showSuccessMessage, 'User deleted!'); }, }); function* oldBackendSagaThree(): SagaIterator { yield takeEvery( DashboardActions.fetchGroupGradingSummary.type, function* (action: ReturnType) { const tokens: Tokens = yield selectTokens(); const groupOverviews: GradingSummary | null = yield call(getGradingSummary, tokens); if (groupOverviews) { yield put(actions.updateGroupGradingSummary(groupOverviews)); } }, ); yield takeEvery( GroundControlActions.changeDateAssessment.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload.id; const closeAt = action.payload.closeAt; const openAt = action.payload.openAt; const resp: Response | null = yield updateAssessment(id, { openAt, closeAt }, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Updated successfully!', 1000); }, ); yield takeEvery( GroundControlActions.changeTeamSizeAssessment.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload.id; const maxTeamSize = action.payload.maxTeamSize; const resp: Response | null = yield updateAssessment(id, { maxTeamSize }, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Team size updated successfully!', 1000); }, ); yield takeEvery( GroundControlActions.deleteAssessment.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload; const resp: Response | null = yield deleteAssessment(id, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Deleted successfully!', 1000); }, ); yield takeEvery( GroundControlActions.publishAssessment.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload.id; const togglePublishTo = action.payload.togglePublishAssessmentTo; const resp: Response | null = yield updateAssessment( id, { isPublished: togglePublishTo }, tokens, ); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); if (togglePublishTo) { yield call(showSuccessMessage, 'Published successfully!', 1000); } else { yield call(showSuccessMessage, 'Unpublished successfully!', 1000); } }, ); yield takeEvery( GroundControlActions.uploadAssessment.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const { file, forceUpdate, assessmentConfigId } = action.payload; const resp: Response | null = yield uploadAssessment( file, tokens, forceUpdate, assessmentConfigId, ); if (!resp || !resp.ok) { return yield handleResponseError(resp); } const respText: string = yield resp.text(); if (respText === 'OK') { yield call(showSuccessMessage, 'Uploaded successfully!', 2000); } else if (respText === 'Force Update OK') { yield call(showSuccessMessage, 'Assessment force updated successfully!', 2000); } yield put(actions.fetchAssessmentOverviews()); }, ); yield takeEvery( GroundControlActions.configureAssessment.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload.id; const hasVotingFeatures = action.payload.hasVotingFeatures; const hasTokenCounter = action.payload.hasTokenCounter; const isAutosaveEnabled = action.payload.isAutosaveEnabled; const resp: Response | null = yield updateAssessment( id, { hasVotingFeatures, hasTokenCounter, isAutosaveEnabled }, tokens, ); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Updated successfully!', 1000); }, ); yield takeEvery( GroundControlActions.assignEntriesForVoting.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload.id; const resp: Response | null = yield updateAssessment( id, { assignEntriesForVoting: true, }, tokens, ); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Updated successfully!', 1000); }, ); yield takeEvery( GroundControlActions.publishGradingAll.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload; const resp: Response | null = yield publishGradingAll(id, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Published all graded submissons successfully!', 1000); }, ); yield takeEvery( GroundControlActions.unpublishGradingAll.type, function* (action: ReturnType): any { const tokens: Tokens = yield selectTokens(); const id = action.payload; const resp: Response | null = yield unpublishGradingAll(id, tokens); if (!resp || !resp.ok) { return yield handleResponseError(resp); } yield put(actions.fetchAssessmentOverviews()); yield call(showSuccessMessage, 'Unpublished all submissons successfully!', 1000); }, ); } function* handleReautogradeResponse(resp: Response | null): any { if (resp && resp.ok) { return yield call(showSuccessMessage, 'Autograde job queued successfully.'); } if (resp && resp.status === 400) { return yield call(showWarningMessage, 'Cannot reautograde non-submitted submission.'); } return yield call(showWarningMessage, 'Failed to queue autograde job.'); } function* BackendSaga(): SagaIterator { yield all([fork(newBackendSagaOne), fork(newBackendSagaTwo), fork(oldBackendSagaThree)]); } export default BackendSaga;