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;