CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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