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