Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/course/students/actions.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Actions specific to manipulating the students in a course7*/89import { delay, map } from "awaiting";10import { redux } from "@cocalc/frontend/app-framework";11import { webapp_client } from "@cocalc/frontend/webapp-client";12import { callback2 } from "@cocalc/util/async-utils";13import { defaults, required, uuid } from "@cocalc/util/misc";14import { reuseInFlight } from "@cocalc/util/reuse-in-flight";15import { CourseActions } from "../actions";16import { CourseStore, StudentRecord } from "../store";17import type { SyncDBRecordStudent } from "../types";18import { Map as iMap } from "immutable";1920const STUDENT_STATUS_UPDATE_MS = 60 * 1000;2122export class StudentsActions {23private course_actions: CourseActions;24private updateInterval?;2526constructor(course_actions: CourseActions) {27this.course_actions = course_actions;28this.push_missing_handouts_and_assignments = reuseInFlight(29this.push_missing_handouts_and_assignments.bind(this),30);31setTimeout(this.updateStudentStatus, 5000);32this.updateInterval = setInterval(33this.updateStudentStatus,34STUDENT_STATUS_UPDATE_MS,35);36}3738private get_store(): CourseStore {39const store = this.course_actions.get_store();40if (store == null) throw Error("no store");41return store;42}4344public async add_students(45students: { account_id?: string; email_address?: string }[],46): Promise<void> {47// students = array of objects that may have an account_id or email_address field set48// New student_id's will be constructed randomly for each student49const student_ids: string[] = [];50for (const x of students) {51if (x.account_id == null && x.email_address == null) continue; // nothing to do52const student_id = uuid();53student_ids.push(student_id);54const y = x as SyncDBRecordStudent;55y.table = "students";56y.student_id = student_id;57this.course_actions.syncdb.set(y);58}59this.course_actions.syncdb.commit();60const f: (student_id: string) => Promise<void> = async (student_id) => {61let store = this.get_store();62await callback2(store.wait, {63until: (store: CourseStore) => store.get_student(student_id),64timeout: 60,65});66this.course_actions.student_projects.create_student_project(student_id);67store = this.get_store();68await callback2(store.wait, {69until: (store: CourseStore) =>70store.getIn(["students", student_id, "project_id"]),71timeout: 60,72});73};7475const id = this.course_actions.set_activity({76desc: `Creating ${students.length} student projects (do not close the course until done)`,77});7879try {80await map(student_ids, this.get_store().get_copy_parallel(), f);81} catch (err) {82if (this.course_actions.is_closed()) return;83this.course_actions.set_error(84`error creating student projects -- ${err}`,85);86} finally {87if (this.course_actions.is_closed()) return;88this.course_actions.set_activity({ id });89// after adding students, always run configure all projects,90// to ensure everything is set properly91await this.course_actions.student_projects.configure_all_projects();92}93}9495public async delete_student(96student_id: string,97noTrash = false,98): Promise<void> {99const store = this.get_store();100const student = store.get_student(student_id);101if (student == null) {102return;103}104this.doDeleteStudent(student, noTrash);105// We always remove any deleted student from all student projects and the106// shared project when they are deleted, since this best aligns with107// user expectations. We do this, even if "allow collaborators" is enabled.108await this.course_actions.student_projects.removeFromAllStudentProjects(109student,110);111}112113undelete_student = async (student_id: string): Promise<void> => {114this.course_actions.set({115deleted: false,116student_id,117table: "students",118});119// configure, since they may get added back to shared project, etc.120await delay(1); // so store is updated, since it is used by configure121await this.course_actions.student_projects.configure_all_projects();122};123124deleteAllStudents = async (noTrash = false): Promise<void> => {125const store = this.get_store();126const students = store.get_students().valueSeq().toArray();127for (const student of students) {128this.doDeleteStudent(student, noTrash, false);129}130this.course_actions.syncdb.commit();131await delay(1); // so store is updated, since it is used by configure132await this.course_actions.student_projects.configure_all_projects();133};134135private doDeleteStudent = (136student: StudentRecord,137noTrash = false,138commit = true,139): void => {140const project_id = student.get("project_id");141if (project_id != null) {142// The student's project was created so let's clear any upgrades from it.143redux.getActions("projects").clear_project_upgrades(project_id);144}145if (noTrash) {146this.course_actions.delete(147{148student_id: student.get("student_id"),149table: "students",150},151commit,152);153} else {154this.course_actions.set(155{156deleted: true,157student_id: student.get("student_id"),158table: "students",159},160commit,161);162}163};164165// Some students might *only* have been added using their email address, but they166// subsequently signed up for an CoCalc account. We check for any of these and if167// we find any, we add in the account_id information about that student.168lookupNonregisteredStudents = async (): Promise<void> => {169const store = this.get_store();170const v: { [email: string]: string } = {};171const s: string[] = [];172store.get_students().map((student: StudentRecord, student_id: string) => {173if (!student.get("account_id") && !student.get("deleted")) {174const email = student.get("email_address");175if (email) {176v[email] = student_id;177s.push(email);178}179}180});181if (s.length == 0) {182return;183}184try {185const result = await webapp_client.users_client.user_search({186query: s.join(","),187limit: s.length,188only_email: true,189});190for (const x of result) {191if (x.email_address == null) {192continue;193}194this.course_actions.set({195student_id: v[x.email_address],196account_id: x.account_id,197table: "students",198});199}200} catch (err) {201// Non-fatal, will try again next time lookupNonregisteredStudents gets called.202console.warn(`lookupNonregisteredStudents: search error -- ${err}`);203}204};205206// For every student with a known account_id, verify that their207// account still exists, and if not, mark it as deleted. This is rare, but happens208// despite all attempts otherwise: https://github.com/sagemathinc/cocalc/issues/3243209updateDeletedAccounts = async () => {210const store = this.get_store();211const account_ids: string[] = [];212const student_ids: { [account_id: string]: string } = {};213store.get_students().map((student: StudentRecord) => {214const account_id = student.get("account_id");215if (account_id && !student.get("deleted_account")) {216account_ids.push(account_id);217student_ids[account_id] = student.get("student_id");218}219});220if (account_ids.length == 0) {221return;222}223// note: there is no notion of undeleting an account in cocalc224const users = await webapp_client.users_client.getNames(account_ids);225for (const account_id of account_ids) {226if (users[account_id] == null) {227this.course_actions.set({228student_id: student_ids[account_id],229account_id,230table: "students",231deleted_account: true,232});233}234}235};236237updateStudentStatus = async () => {238const state = this.course_actions.syncdb?.get_state();239if (state == "init") {240return;241}242if (state != "ready") {243clearInterval(this.updateInterval);244delete this.updateInterval;245return;246}247await this.lookupNonregisteredStudents();248await this.updateDeletedAccounts();249};250251// columns: first_name, last_name, email, last_active, hosting252// Toggles ascending/decending order253set_active_student_sort = (column_name: string): void => {254let is_descending: boolean;255const store = this.get_store();256const current_column = store.getIn(["active_student_sort", "column_name"]);257if (current_column === column_name) {258is_descending = !store.getIn(["active_student_sort", "is_descending"]);259} else {260is_descending = false;261}262this.course_actions.setState({263active_student_sort: { column_name, is_descending },264});265};266267set_internal_student_info = async (268student_id: string,269info: { first_name: string; last_name: string; email_address?: string },270): Promise<void> => {271const { student } = this.course_actions.resolve({ student_id });272if (student == null) return;273274info = defaults(info, {275first_name: required,276last_name: required,277email_address: student.get("email_address"),278});279280this.course_actions.set({281first_name: info.first_name,282last_name: info.last_name,283email_address: info.email_address,284student_id,285table: "students",286});287288// since they may get removed from shared project, etc.289await this.course_actions.student_projects.configure_all_projects();290};291292set_student_note = (student_id: string, note: string): void => {293this.course_actions.set({294note,295table: "students",296student_id,297});298};299300/*301Function to "catch up a student" by pushing out all (non-deleted) handouts and assignments to302this student that have been pushed to at least one student so far.303*/304push_missing_handouts_and_assignments = async (305student_id: string,306): Promise<void> => {307const { student, store } = this.course_actions.resolve({ student_id });308if (student == null) {309throw Error("no such student");310}311const name = store.get_student_name(student_id);312const id = this.course_actions.set_activity({313desc: `Catching up ${name}...`,314});315try {316for (const assignment_id of store.get_assigned_assignment_ids()) {317if (318!store.student_assignment_info(student_id, assignment_id)319.last_assignment320) {321await this.course_actions.assignments.copy_assignment(322"assigned",323assignment_id,324student_id,325);326if (this.course_actions.is_closed()) return;327}328}329for (const handout_id of store.get_assigned_handout_ids()) {330if (store.student_handout_info(student_id, handout_id).status == null) {331await this.course_actions.handouts.copy_handout_to_student(332handout_id,333student_id,334true,335);336if (this.course_actions.is_closed()) return;337}338}339} finally {340this.course_actions.set_activity({ id });341}342};343344setAssignmentFilter = (student_id: string, filter: string) => {345const store = this.get_store();346if (!store) return;347let assignmentFilter = store.get("assignmentFilter");348if (assignmentFilter == null) {349if (filter) {350assignmentFilter = iMap({ [student_id]: filter });351this.course_actions.setState({352assignmentFilter,353});354}355return;356}357assignmentFilter = assignmentFilter.set(student_id, filter);358this.course_actions.setState({ assignmentFilter });359};360}361362363