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/configuration/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 involving configuration of the course.
8
*/
9
10
import { redux } from "@cocalc/frontend/app-framework";
11
import {
12
derive_project_img_name,
13
SoftwareEnvironmentState,
14
} from "@cocalc/frontend/custom-software/selector";
15
import { Datastore, EnvVars } from "@cocalc/frontend/projects/actions";
16
import { store as projects_store } from "@cocalc/frontend/projects/store";
17
import { webapp_client } from "@cocalc/frontend/webapp-client";
18
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
19
import { CourseActions, primary_key } from "../actions";
20
import {
21
DEFAULT_LICENSE_UPGRADE_HOST_PROJECT,
22
CourseSettingsRecord,
23
PARALLEL_DEFAULT,
24
} from "../store";
25
import { SiteLicenseStrategy, SyncDBRecord, UpgradeGoal } from "../types";
26
import {
27
StudentProjectFunctionality,
28
completeStudentProjectFunctionality,
29
} from "./customize-student-project-functionality";
30
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
31
import { delay } from "awaiting";
32
import {
33
NBGRADER_CELL_TIMEOUT_MS,
34
NBGRADER_MAX_OUTPUT,
35
NBGRADER_MAX_OUTPUT_PER_CELL,
36
NBGRADER_TIMEOUT_MS,
37
} from "../assignments/consts";
38
39
interface ConfigurationTarget {
40
project_id: string;
41
path: string;
42
}
43
44
export class ConfigurationActions {
45
private course_actions: CourseActions;
46
private configuring: boolean = false;
47
private configureAgain: boolean = false;
48
49
constructor(course_actions: CourseActions) {
50
this.course_actions = course_actions;
51
this.push_missing_handouts_and_assignments = reuseInFlight(
52
this.push_missing_handouts_and_assignments.bind(this),
53
);
54
}
55
56
set = (obj: SyncDBRecord, commit: boolean = true): void => {
57
this.course_actions.set(obj, commit);
58
};
59
60
set_title = (title: string): void => {
61
this.set({ title, table: "settings" });
62
this.course_actions.student_projects.set_all_student_project_titles(title);
63
this.course_actions.shared_project.set_project_title();
64
};
65
66
set_description = (description: string): void => {
67
this.set({ description, table: "settings" });
68
this.course_actions.student_projects.set_all_student_project_descriptions(
69
description,
70
);
71
this.course_actions.shared_project.set_project_description();
72
};
73
74
// NOTE: site_license_id can be a single id, or multiple id's separate by a comma.
75
add_site_license_id = (license_id: string): void => {
76
const store = this.course_actions.get_store();
77
let site_license_id = store.getIn(["settings", "site_license_id"]) ?? "";
78
if (site_license_id.indexOf(license_id) != -1) return; // already known
79
site_license_id += (site_license_id.length > 0 ? "," : "") + license_id;
80
this.set({ site_license_id, table: "settings" });
81
};
82
83
remove_site_license_id = (license_id: string): void => {
84
const store = this.course_actions.get_store();
85
let cur = store.getIn(["settings", "site_license_id"]) ?? "";
86
let removed = store.getIn(["settings", "site_license_removed"]) ?? "";
87
if (cur.indexOf(license_id) == -1) return; // already removed
88
const v: string[] = [];
89
for (const id of cur.split(",")) {
90
if (id != license_id) {
91
v.push(id);
92
}
93
}
94
const site_license_id = v.join(",");
95
if (!removed.includes(license_id)) {
96
removed = removed.split(",").concat([license_id]).join(",");
97
}
98
this.set({
99
site_license_id,
100
site_license_removed: removed,
101
table: "settings",
102
});
103
};
104
105
set_site_license_strategy = (
106
site_license_strategy: SiteLicenseStrategy,
107
): void => {
108
this.set({ site_license_strategy, table: "settings" });
109
};
110
111
set_pay_choice = (type: "student" | "institute", value: boolean): void => {
112
this.set({ [type + "_pay"]: value, table: "settings" });
113
if (type == "student") {
114
if (!value) {
115
this.setStudentPay({ when: "" });
116
}
117
}
118
};
119
120
set_upgrade_goal = (upgrade_goal: UpgradeGoal): void => {
121
this.set({ upgrade_goal, table: "settings" });
122
};
123
124
set_allow_collabs = (allow_collabs: boolean): void => {
125
this.set({ allow_collabs, table: "settings" });
126
this.course_actions.student_projects.configure_all_projects();
127
};
128
129
set_student_project_functionality = async (
130
student_project_functionality: StudentProjectFunctionality,
131
): Promise<void> => {
132
this.set({ student_project_functionality, table: "settings" });
133
await this.course_actions.student_projects.configure_all_projects();
134
};
135
136
set_email_invite = (body: string): void => {
137
this.set({ email_invite: body, table: "settings" });
138
};
139
140
// Set the pay option for the course, and ensure that the course fields are
141
// set on every student project in the course (see schema.coffee for format
142
// of the course field) to reflect this change in the database.
143
setStudentPay = async ({
144
when,
145
info,
146
cost,
147
}: {
148
when?: Date | string; // date when they need to pay
149
info?: PurchaseInfo; // what they must buy for the course
150
cost?: number;
151
}) => {
152
const value = {
153
...(info != null ? { payInfo: info } : undefined),
154
...(when != null
155
? { pay: typeof when != "string" ? when.toISOString() : when }
156
: undefined),
157
...(cost != null ? { payCost: cost } : undefined),
158
};
159
const store = this.course_actions.get_store();
160
// wait until store changes with new settings, then configure student projects
161
store.once("change", async () => {
162
await this.course_actions.student_projects.set_all_student_project_course_info();
163
});
164
await this.set({
165
table: "settings",
166
...value,
167
});
168
};
169
170
configure_host_project = async (): Promise<void> => {
171
const id = this.course_actions.set_activity({
172
desc: "Configuring host project.",
173
});
174
try {
175
// NOTE: we never remove it or any other licenses from the host project,
176
// since instructor may want to augment license with another license.
177
const store = this.course_actions.get_store();
178
// be explicit about copying all course licenses to host project
179
// https://github.com/sagemathinc/cocalc/issues/5360
180
const license_upgrade_host_project =
181
store.getIn(["settings", "license_upgrade_host_project"]) ??
182
DEFAULT_LICENSE_UPGRADE_HOST_PROJECT;
183
if (license_upgrade_host_project) {
184
const site_license_id = store.getIn(["settings", "site_license_id"]);
185
const actions = redux.getActions("projects");
186
const course_project_id = store.get("course_project_id");
187
if (site_license_id) {
188
await actions.add_site_license_to_project(
189
course_project_id,
190
site_license_id,
191
);
192
}
193
}
194
} catch (err) {
195
this.course_actions.set_error(`Error configuring host project - ${err}`);
196
} finally {
197
this.course_actions.set_activity({ id });
198
}
199
};
200
201
configure_all_projects = async (force: boolean = false): Promise<void> => {
202
if (this.configuring) {
203
// Important -- if configure_all_projects is called *while* it is running,
204
// wait until it is done, then call it again (though I'm being lazy about the
205
// await!). Don't do the actual work more than once
206
// at the same time since that might confuse the db writes, but
207
// also don't just reuse in flight, which will miss the later calls.
208
this.configureAgain = true;
209
return;
210
}
211
try {
212
this.configureAgain = false;
213
this.configuring = true;
214
await this.course_actions.shared_project.configure();
215
await this.configure_host_project();
216
await this.course_actions.student_projects.configure_all_projects(force);
217
await this.configure_nbgrader_grade_project();
218
} finally {
219
this.configuring = false;
220
if (this.configureAgain) {
221
this.configureAgain = false;
222
this.configure_all_projects();
223
}
224
}
225
};
226
227
push_missing_handouts_and_assignments = async (): Promise<void> => {
228
const store = this.course_actions.get_store();
229
for (const student_id of store.get_student_ids({ deleted: false })) {
230
await this.course_actions.students.push_missing_handouts_and_assignments(
231
student_id,
232
);
233
}
234
};
235
236
set_copy_parallel = (copy_parallel: number = PARALLEL_DEFAULT): void => {
237
this.set({
238
copy_parallel,
239
table: "settings",
240
});
241
};
242
243
configure_nbgrader_grade_project = async (
244
project_id?: string,
245
): Promise<void> => {
246
let store;
247
try {
248
store = this.course_actions.get_store();
249
} catch (_) {
250
// this could get called during grading that is ongoing right when
251
// the user decides to close the document, and in that case get_store()
252
// would throw an error: https://github.com/sagemathinc/cocalc/issues/7050
253
return;
254
}
255
256
if (project_id == null) {
257
project_id = store.getIn(["settings", "nbgrader_grade_project"]);
258
}
259
if (project_id == null || project_id == "") return;
260
261
const id = this.course_actions.set_activity({
262
desc: "Configuring grading project.",
263
});
264
265
try {
266
// make sure the course config for that nbgrader project (mainly for the datastore!) is set
267
const datastore: Datastore = store.get_datastore();
268
const envvars: EnvVars = store.get_envvars();
269
const projects_actions = redux.getActions("projects");
270
271
// if for some reason this is a student project, we don't want to reconfigure it
272
const course_info: any = projects_store
273
.get_course_info(project_id)
274
?.toJS();
275
if (course_info?.type == null || course_info.type == "nbgrader") {
276
await projects_actions.set_project_course_info({
277
project_id,
278
course_project_id: store.get("course_project_id"),
279
path: store.get("course_filename"),
280
pay: "", // pay
281
payInfo: null,
282
account_id: null,
283
email_address: null,
284
datastore,
285
type: "nbgrader",
286
envvars,
287
});
288
}
289
290
// we also make sure all teachers have access to that project – otherwise NBGrader can't work, etc.
291
// this has to happen *after* setting the course field, extended access control, ...
292
const ps = redux.getStore("projects");
293
const teachers = ps.get_users(store.get("course_project_id"));
294
const users_of_grade_project = ps.get_users(project_id);
295
if (users_of_grade_project != null && teachers != null) {
296
for (const account_id of teachers.keys()) {
297
const user = users_of_grade_project.get(account_id);
298
if (user != null) continue;
299
await webapp_client.project_collaborators.add_collaborator({
300
account_id,
301
project_id,
302
});
303
}
304
}
305
} catch (err) {
306
this.course_actions.set_error(
307
`Error configuring grading project - ${err}`,
308
);
309
} finally {
310
this.course_actions.set_activity({ id });
311
}
312
};
313
314
// project_id is a uuid *or* empty string.
315
set_nbgrader_grade_project = async (
316
project_id: string = "",
317
): Promise<void> => {
318
this.set({
319
nbgrader_grade_project: project_id,
320
table: "settings",
321
});
322
323
// not empty string → configure that grading project
324
if (project_id) {
325
await this.configure_nbgrader_grade_project(project_id);
326
}
327
};
328
329
set_nbgrader_cell_timeout_ms = (
330
nbgrader_cell_timeout_ms: number = NBGRADER_CELL_TIMEOUT_MS,
331
): void => {
332
this.set({
333
nbgrader_cell_timeout_ms,
334
table: "settings",
335
});
336
};
337
338
set_nbgrader_timeout_ms = (
339
nbgrader_timeout_ms: number = NBGRADER_TIMEOUT_MS,
340
): void => {
341
this.set({
342
nbgrader_timeout_ms,
343
table: "settings",
344
});
345
};
346
347
set_nbgrader_max_output = (
348
nbgrader_max_output: number = NBGRADER_MAX_OUTPUT,
349
): void => {
350
this.set({
351
nbgrader_max_output,
352
table: "settings",
353
});
354
};
355
356
set_nbgrader_max_output_per_cell = (
357
nbgrader_max_output_per_cell: number = NBGRADER_MAX_OUTPUT_PER_CELL,
358
): void => {
359
this.set({
360
nbgrader_max_output_per_cell,
361
table: "settings",
362
});
363
};
364
365
set_nbgrader_include_hidden_tests = (value: boolean): void => {
366
this.set({
367
nbgrader_include_hidden_tests: value,
368
table: "settings",
369
});
370
};
371
372
set_inherit_compute_image = (image?: string): void => {
373
this.set({ inherit_compute_image: image != null, table: "settings" });
374
if (image != null) {
375
this.set_compute_image(image);
376
}
377
};
378
379
set_compute_image = (image: string) => {
380
this.set({
381
custom_image: image,
382
table: "settings",
383
});
384
this.course_actions.student_projects.configure_all_projects();
385
this.course_actions.shared_project.set_project_compute_image();
386
};
387
388
set_software_environment = async (
389
state: SoftwareEnvironmentState,
390
): Promise<void> => {
391
const image = await derive_project_img_name(state);
392
this.set_compute_image(image);
393
};
394
395
set_nbgrader_parallel = (
396
nbgrader_parallel: number = PARALLEL_DEFAULT,
397
): void => {
398
this.set({
399
nbgrader_parallel,
400
table: "settings",
401
});
402
};
403
404
set_datastore = (datastore: Datastore): void => {
405
this.set({ datastore, table: "settings" });
406
setTimeout(() => {
407
this.configure_all_projects_shared_and_nbgrader();
408
}, 1);
409
};
410
411
set_envvars = (inherit: boolean): void => {
412
this.set({ envvars: { inherit }, table: "settings" });
413
setTimeout(() => {
414
this.configure_all_projects_shared_and_nbgrader();
415
}, 1);
416
};
417
418
set_license_upgrade_host_project = (upgrade: boolean): void => {
419
this.set({ license_upgrade_host_project: upgrade, table: "settings" });
420
setTimeout(() => {
421
this.configure_host_project();
422
}, 1);
423
};
424
425
private configure_all_projects_shared_and_nbgrader = () => {
426
this.course_actions.student_projects.configure_all_projects();
427
this.course_actions.shared_project.set_datastore_and_envvars();
428
// in case there is a separate nbgrader project, we have to set the envvars as well
429
this.configure_nbgrader_grade_project();
430
};
431
432
purgeDeleted = (): void => {
433
const { syncdb } = this.course_actions;
434
for (const record of syncdb.get()) {
435
if (record?.get("deleted")) {
436
for (const table in primary_key) {
437
const key = primary_key[table];
438
if (record.get(key)) {
439
syncdb.delete({ [key]: record.get(key) });
440
break;
441
}
442
}
443
}
444
}
445
syncdb.commit();
446
};
447
448
copyConfiguration = async ({
449
groups,
450
targets,
451
}: {
452
groups: ConfigurationGroup[];
453
targets: ConfigurationTarget[];
454
}) => {
455
const store = this.course_actions.get_store();
456
if (groups.length == 0 || targets.length == 0 || store == null) {
457
return;
458
}
459
const settings = store.get("settings");
460
for (const target of targets) {
461
const targetActions = await openCourseFileAndGetActions({
462
...target,
463
maxTimeMs: 30000,
464
});
465
for (const group of groups) {
466
await configureGroup({
467
group,
468
settings,
469
actions: targetActions.course_actions,
470
});
471
}
472
}
473
// switch back
474
const { project_id, path } = this.course_actions.syncdb;
475
redux.getProjectActions(project_id).open_file({ path, foreground: true });
476
};
477
}
478
479
async function openCourseFileAndGetActions({ project_id, path, maxTimeMs }) {
480
await redux
481
.getProjectActions(project_id)
482
.open_file({ path, foreground: true });
483
const t = Date.now();
484
let d = 250;
485
while (Date.now() + d - t <= maxTimeMs) {
486
await delay(d);
487
const targetActions = redux.getEditorActions(project_id, path);
488
if (targetActions?.course_actions?.syncdb.get_state() == "ready") {
489
return targetActions;
490
}
491
d *= 1.1;
492
}
493
throw Error(`unable to open '${path}'`);
494
}
495
496
export const CONFIGURATION_GROUPS = [
497
"collaborator-policy",
498
"email-invitation",
499
"copy-limit",
500
"restrict-student-projects",
501
"nbgrader",
502
"upgrades",
503
// "network-file-systems",
504
// "env-variables",
505
// "software-environment",
506
] as const;
507
508
export type ConfigurationGroup = (typeof CONFIGURATION_GROUPS)[number];
509
510
async function configureGroup({
511
group,
512
settings,
513
actions,
514
}: {
515
group: ConfigurationGroup;
516
settings: CourseSettingsRecord;
517
actions: CourseActions;
518
}) {
519
switch (group) {
520
case "collaborator-policy":
521
const allow_colabs = !!settings.get("allow_collabs");
522
actions.configuration.set_allow_collabs(allow_colabs);
523
return;
524
case "email-invitation":
525
actions.configuration.set_email_invite(settings.get("email_invite"));
526
return;
527
case "copy-limit":
528
actions.configuration.set_copy_parallel(settings.get("copy_parallel"));
529
return;
530
case "restrict-student-projects":
531
actions.configuration.set_student_project_functionality(
532
completeStudentProjectFunctionality(
533
settings.get("student_project_functionality")?.toJS() ?? {},
534
),
535
);
536
return;
537
case "nbgrader":
538
await actions.configuration.set_nbgrader_grade_project(
539
settings.get("nbgrader_grade_project"),
540
);
541
await actions.configuration.set_nbgrader_cell_timeout_ms(
542
settings.get("nbgrader_cell_timeout_ms"),
543
);
544
await actions.configuration.set_nbgrader_timeout_ms(
545
settings.get("nbgrader_timeout_ms"),
546
);
547
await actions.configuration.set_nbgrader_max_output(
548
settings.get("nbgrader_max_output"),
549
);
550
await actions.configuration.set_nbgrader_max_output_per_cell(
551
settings.get("nbgrader_max_output_per_cell"),
552
);
553
await actions.configuration.set_nbgrader_include_hidden_tests(
554
!!settings.get("nbgrader_include_hidden_tests"),
555
);
556
return;
557
558
case "upgrades":
559
if (settings.get("student_pay")) {
560
actions.configuration.set_pay_choice("student", true);
561
await actions.configuration.setStudentPay({
562
when: settings.get("pay"),
563
info: settings.get("payInfo")?.toJS(),
564
cost: settings.get("payCost"),
565
});
566
await actions.configuration.configure_all_projects();
567
} else {
568
actions.configuration.set_pay_choice("student", false);
569
}
570
if (settings.get("institute_pay")) {
571
actions.configuration.set_pay_choice("institute", true);
572
const strategy = settings.get("set_site_license_strategy");
573
if (strategy != null) {
574
actions.configuration.set_site_license_strategy(strategy);
575
}
576
const site_license_id = settings.get("site_license_id");
577
actions.configuration.set({ site_license_id, table: "settings" });
578
} else {
579
actions.configuration.set_pay_choice("institute", false);
580
}
581
return;
582
583
// case "network-file-systems":
584
// case "env-variables":
585
// case "software-environment":
586
default:
587
throw Error(`configuring group ${group} not implemented`);
588
}
589
}
590
591