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/student-projects/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 student projects that students have in a course.
8
*/
9
10
import { delay, map as awaitMap } from "awaiting";
11
import { sortBy } from "lodash";
12
import { redux } from "@cocalc/frontend/app-framework";
13
import { markdown_to_html } from "@cocalc/frontend/markdown";
14
import { Datastore, EnvVars } from "@cocalc/frontend/projects/actions";
15
import { webapp_client } from "@cocalc/frontend/webapp-client";
16
import { RESEND_INVITE_INTERVAL_DAYS } from "@cocalc/util/consts/invites";
17
import { copy, days_ago, keys, len } from "@cocalc/util/misc";
18
import { SITE_NAME } from "@cocalc/util/theme";
19
import { CourseActions } from "../actions";
20
import { CourseStore } from "../store";
21
import { UpgradeGoal } from "../types";
22
import { Result, run_in_all_projects } from "./run-in-all-projects";
23
import type { StudentRecord } from "../store";
24
25
// for tasks that are "easy" to run in parallel, e.g. starting projects
26
export const MAX_PARALLEL_TASKS = 30;
27
28
export const RESEND_INVITE_BEFORE = days_ago(RESEND_INVITE_INTERVAL_DAYS);
29
export class StudentProjectsActions {
30
private course_actions: CourseActions;
31
32
constructor(course_actions: CourseActions) {
33
this.course_actions = course_actions;
34
}
35
36
private get_store = (): CourseStore => {
37
const store = this.course_actions.get_store();
38
if (store == null) throw Error("no store");
39
return store;
40
};
41
42
// Create and configure a single student project.
43
create_student_project = async (
44
student_id: string,
45
): Promise<string | undefined> => {
46
const { store, student } = this.course_actions.resolve({
47
student_id,
48
finish: this.course_actions.set_error.bind(this),
49
});
50
if (store == null || student == null) return;
51
if (store.get("students") == null || store.get("settings") == null) {
52
this.course_actions.set_error(
53
"BUG: attempt to create when stores not yet initialized",
54
);
55
return;
56
}
57
if (student.get("project_id")) {
58
// project already created.
59
return student.get("project_id");
60
}
61
this.course_actions.set({
62
create_project: webapp_client.server_time(),
63
table: "students",
64
student_id,
65
});
66
const id = this.course_actions.set_activity({
67
desc: `Create project for ${store.get_student_name(student_id)}.`,
68
});
69
const dflt_img = await redux.getStore("customize").getDefaultComputeImage();
70
let project_id: string;
71
try {
72
project_id = await redux.getActions("projects").create_project({
73
title: store.get("settings").get("title"),
74
description: store.get("settings").get("description"),
75
image: store.get("settings").get("custom_image") ?? dflt_img,
76
noPool: true, // student is unlikely to use the project right *now*
77
});
78
} catch (err) {
79
this.course_actions.set_error(
80
`error creating student project for ${store.get_student_name(
81
student_id,
82
)} -- ${err}`,
83
);
84
return;
85
} finally {
86
this.course_actions.clear_activity(id);
87
}
88
this.course_actions.set({
89
create_project: null,
90
project_id,
91
table: "students",
92
student_id,
93
});
94
await this.configure_project({
95
student_id,
96
student_project_id: project_id,
97
});
98
return project_id;
99
};
100
101
// if student is an email address, invite via email – otherwise, if account_id, invite via standard collaborator invite
102
invite_student_to_project = async (props: {
103
student_id: string;
104
student: string; // could be account_id or email_address
105
student_project_id?: string;
106
}) => {
107
const { student_id, student, student_project_id } = props;
108
if (student_project_id == null) return;
109
110
// console.log("invite", x, " to ", student_project_id);
111
if (student.includes("@")) {
112
const store = this.get_store();
113
if (store == null) return;
114
const account_store = redux.getStore("account");
115
const name = account_store.get_fullname();
116
const replyto = account_store.get_email_address();
117
const title = store.get("settings").get("title");
118
const site_name =
119
redux.getStore("customize").get("site_name") ?? SITE_NAME;
120
const subject = `${site_name} Invitation to Course ${title}`;
121
let body = store.get_email_invite();
122
body = body.replace(/{title}/g, title).replace(/{name}/g, name);
123
body = markdown_to_html(body);
124
await redux
125
.getActions("projects")
126
.invite_collaborators_by_email(
127
student_project_id,
128
student,
129
body,
130
subject,
131
true,
132
replyto,
133
name,
134
);
135
this.course_actions.set({
136
table: "students",
137
student_id,
138
last_email_invite: webapp_client.server_time(),
139
});
140
} else {
141
await redux
142
.getActions("projects")
143
.invite_collaborator(student_project_id, student);
144
}
145
};
146
147
private configure_project_users = async (props: {
148
student_project_id: string;
149
student_id: string;
150
force_send_invite_by_email?: boolean;
151
}): Promise<void> => {
152
const {
153
student_project_id,
154
student_id,
155
force_send_invite_by_email = false,
156
} = props;
157
//console.log("configure_project_users", student_project_id, student_id)
158
// Add student and all collaborators on this project to the project with given project_id.
159
// users = who is currently a user of the student's project?
160
const users = redux.getStore("projects").get_users(student_project_id); // immutable.js map
161
if (users == null) return; // can't do anything if this isn't known...
162
163
const s = this.get_store();
164
if (s == null) return;
165
const student = s.get_student(student_id);
166
if (student == null) return; // no such student..
167
168
// Make sure the student is on the student's project:
169
const student_account_id = student.get("account_id");
170
if (student_account_id == null) {
171
// No known account yet, so invite by email.
172
// This is done once and then on demand by the teacher – only limited to once per day or less
173
const last_email_invite = student.get("last_email_invite");
174
if (force_send_invite_by_email || !last_email_invite) {
175
const email_address = student.get("email_address");
176
if (email_address) {
177
await this.invite_student_to_project({
178
student_id,
179
student: email_address,
180
student_project_id,
181
});
182
this.course_actions.set({
183
table: "students",
184
student_id,
185
last_email_invite: webapp_client.server_time(),
186
});
187
}
188
}
189
} else if (
190
(users != null ? users.get(student_account_id) : undefined) == null
191
) {
192
// users might not be set yet if project *just* created
193
await this.invite_student_to_project({
194
student_id,
195
student: student_account_id,
196
student_project_id,
197
});
198
}
199
200
// Make sure all collaborators on course project are on the student's project:
201
const course_collaborators = redux
202
.getStore("projects")
203
.get_users(s.get("course_project_id"));
204
if (course_collaborators == null) {
205
// console.log("projects store isn't sufficiently initialized yet...");
206
return;
207
}
208
for (const account_id of course_collaborators.keys()) {
209
if (!users.has(account_id)) {
210
await redux
211
.getActions("projects")
212
.invite_collaborator(student_project_id, account_id);
213
}
214
}
215
216
// Regarding student_account_id !== undefined below, see https://github.com/sagemathinc/cocalc/pull/3259
217
// The problem is that student_account_id might not yet be known to the .course, even though
218
// the student has been added and the account_id exists, and is known to the account opening
219
// the .course file. This is just due to a race condition somewhere else. For now -- before
220
// just factoring out and rewriting all this code better -- we at least make this one change
221
// so the student isn't "brutally" kicked out of the course.
222
if (
223
s.get("settings") != undefined &&
224
!s.get_allow_collabs() &&
225
student_account_id != undefined
226
) {
227
// Remove anybody extra on the student project
228
for (const account_id of users.keys()) {
229
if (
230
!course_collaborators.has(account_id) &&
231
account_id !== student_account_id
232
) {
233
await redux
234
.getActions("projects")
235
.remove_collaborator(student_project_id, account_id);
236
}
237
}
238
}
239
};
240
241
// Sets the licenses for the given project to the given licenses
242
// from our course configuration. Any licenses already on the
243
// project that are not set at all in our course configure license
244
// list stay unchanged. This way a student can buy their own extra
245
// license and apply it and it stays even when the instructor makes
246
// changes to licenses.
247
private set_project_site_license = async (
248
project_id: string,
249
license_ids: string[],
250
): Promise<void> => {
251
const project_map = redux.getStore("projects").get("project_map");
252
if (project_map == null || project_map.get(project_id) == null) {
253
// do nothing if we're not a collab on the project or info about
254
// it isn't loaded -- this should have been ensured earlier on.
255
return;
256
}
257
const store = this.get_store();
258
if (store == null) return;
259
const currentLicenses: string[] = keys(
260
(project_map.getIn([project_id, "site_license"]) as any)?.toJS() ?? {},
261
);
262
const courseLicenses = new Set(
263
((store.getIn(["settings", "site_license_id"]) as any) ?? "").split(","),
264
);
265
const removedLicenses = new Set(
266
((store.getIn(["settings", "site_license_removed"]) as any) ?? "").split(
267
",",
268
),
269
);
270
const toApply = [...license_ids];
271
for (const id of currentLicenses) {
272
if (!courseLicenses.has(id) && !removedLicenses.has(id)) {
273
toApply.push(id);
274
}
275
}
276
const actions = redux.getActions("projects");
277
await actions.set_site_license(project_id, toApply.join(","));
278
};
279
280
private configure_project_license = async (
281
student_project_id: string,
282
license_id?: string, // if not set, all known licenses
283
): Promise<void> => {
284
if (license_id != null) {
285
await this.set_project_site_license(
286
student_project_id,
287
license_id.split(","),
288
);
289
return;
290
}
291
const store = this.get_store();
292
if (store == null) return;
293
// Set all license keys we have that are known and not
294
// expired. (option = false so cached)
295
const licenses = await store.getLicenses(false);
296
const license_ids: string[] = [];
297
for (const license_id in licenses) {
298
if (!licenses[license_id].expired) {
299
license_ids.push(license_id);
300
}
301
}
302
await this.set_project_site_license(student_project_id, license_ids);
303
};
304
305
private remove_project_license = async (
306
student_project_id: string,
307
): Promise<void> => {
308
const actions = redux.getActions("projects");
309
await actions.set_site_license(student_project_id, "");
310
};
311
312
remove_all_project_licenses = async (): Promise<void> => {
313
const id = this.course_actions.set_activity({
314
desc: "Removing all student project licenses...",
315
});
316
try {
317
const store = this.get_store();
318
if (store == null) return;
319
for (const student of store.get_students().valueSeq().toArray()) {
320
const student_project_id = student.get("project_id");
321
if (student_project_id == null) continue;
322
await this.remove_project_license(student_project_id);
323
}
324
} finally {
325
this.course_actions.set_activity({ id });
326
}
327
};
328
329
private configure_project_visibility = async (
330
student_project_id: string,
331
): Promise<void> => {
332
const users_of_student_project = redux
333
.getStore("projects")
334
.get_users(student_project_id);
335
if (users_of_student_project == null) {
336
// e.g., not defined in admin view mode
337
return;
338
}
339
// Make project not visible to any collaborator on the course project.
340
const store = this.get_store();
341
if (store == null) return;
342
const users = redux
343
.getStore("projects")
344
.get_users(store.get("course_project_id"));
345
if (users == null) {
346
// TODO: should really wait until users is defined, which is a supported thing to do on stores!
347
return;
348
}
349
for (const account_id of users.keys()) {
350
const x = users_of_student_project.get(account_id);
351
if (x != null && !x.get("hide")) {
352
await redux
353
.getActions("projects")
354
.set_project_hide(account_id, student_project_id, true);
355
}
356
}
357
};
358
359
private configure_project_title = async (
360
student_project_id: string,
361
student_id: string,
362
): Promise<void> => {
363
const store = this.get_store();
364
if (store == null) {
365
return;
366
}
367
const title = `${store.get_student_name(student_id)} - ${store
368
.get("settings")
369
.get("title")}`;
370
await redux
371
.getActions("projects")
372
.set_project_title(student_project_id, title);
373
};
374
375
// start or stop projects of all (non-deleted) students running
376
action_all_student_projects = async (
377
action: "start" | "stop",
378
): Promise<void> => {
379
if (!["start", "stop"].includes(action)) {
380
throw new Error(`unknown desired project_action ${action}`);
381
}
382
const a2s = { start: "starting", stop: "stopping" } as const;
383
const state: "starting" | "stopping" = a2s[action];
384
385
this.course_actions.setState({ action_all_projects_state: state });
386
this.course_actions.shared_project.action_shared_project(action);
387
388
const store = this.get_store();
389
390
const projects_actions = redux.getActions("projects");
391
if (projects_actions == null) {
392
throw Error("projects actions must be defined");
393
}
394
395
const selectedAction = (function () {
396
switch (action) {
397
case "start":
398
return projects_actions.start_project.bind(projects_actions);
399
case "stop":
400
return projects_actions.stop_project.bind(projects_actions);
401
}
402
})();
403
404
const task = async (student_project_id) => {
405
if (!student_project_id) return;
406
// abort if canceled
407
if (store.get("action_all_projects_state") !== state) return;
408
// returns true/false, could be useful some day
409
await selectedAction(student_project_id);
410
};
411
412
await awaitMap(store.get_student_project_ids(), MAX_PARALLEL_TASKS, task);
413
};
414
415
cancel_action_all_student_projects = (): void => {
416
this.course_actions.setState({ action_all_projects_state: "any" });
417
};
418
419
run_in_all_student_projects = async ({
420
command,
421
args,
422
timeout,
423
log,
424
}: {
425
command: string;
426
args?: string[];
427
timeout?: number;
428
log?: Function;
429
}): Promise<Result[]> => {
430
// in case "stop all projects" is running
431
this.cancel_action_all_student_projects();
432
433
const store = this.get_store();
434
// calling start also deals with possibility that it's in stop state.
435
const id = this.course_actions.set_activity({
436
desc: "Running a command across all student projects…",
437
});
438
let id1: number | undefined = this.course_actions.set_activity({
439
desc: "Starting projects …",
440
});
441
let i = 0;
442
const student_project_ids = store.get_student_project_ids();
443
const num = student_project_ids.length;
444
445
const clear_id1 = () => {
446
if (id1 != null) {
447
this.course_actions.set_activity({ id: id1 });
448
}
449
};
450
451
const done = (result: Result) => {
452
i += 1;
453
log?.(result);
454
clear_id1();
455
id1 = this.course_actions.set_activity({
456
desc: `Project ${i}/${num} finished…`,
457
});
458
};
459
460
try {
461
return await run_in_all_projects(
462
// as string[] is right since map option isn't set (make typescript happy)
463
student_project_ids,
464
command,
465
args,
466
timeout,
467
done,
468
);
469
} finally {
470
this.course_actions.set_activity({ id });
471
clear_id1();
472
}
473
};
474
475
set_all_student_project_titles = async (title: string): Promise<void> => {
476
const actions = redux.getActions("projects");
477
const store = this.get_store();
478
for (const student of store.get_students().valueSeq().toArray()) {
479
const student_project_id = student.get("project_id");
480
const project_title = `${store.get_student_name(
481
student.get("student_id"),
482
)} - ${title}`;
483
if (student_project_id != null) {
484
await actions.set_project_title(student_project_id, project_title);
485
if (this.course_actions.is_closed()) return;
486
}
487
}
488
};
489
490
private configure_project_description = async (
491
student_project_id: string,
492
): Promise<void> => {
493
const store = this.get_store();
494
await redux
495
.getActions("projects")
496
.set_project_description(
497
student_project_id,
498
store.getIn(["settings", "description"]),
499
);
500
};
501
502
set_all_student_project_descriptions = async (
503
description: string,
504
): Promise<void> => {
505
const store = this.get_store();
506
const actions = redux.getActions("projects");
507
for (const student of store.get_students().valueSeq().toArray()) {
508
const student_project_id = student.get("project_id");
509
if (student_project_id != null) {
510
await actions.set_project_description(student_project_id, description);
511
if (this.course_actions.is_closed()) return;
512
}
513
}
514
};
515
516
set_all_student_project_course_info = async (): Promise<void> => {
517
const store = this.get_store();
518
if (store == null) return;
519
let pay = store.get_pay() ?? "";
520
const payInfo = store.get_payInfo();
521
522
if (pay != "" && !(pay instanceof Date)) {
523
// pay *must* be a Date, not just a string timestamp... or "" for not paying.
524
pay = new Date(pay);
525
}
526
527
const datastore: Datastore = store.get_datastore();
528
const envvars: EnvVars = store.get_envvars();
529
const student_project_functionality = store
530
.getIn(["settings", "student_project_functionality"])
531
?.toJS();
532
533
const actions = redux.getActions("projects");
534
const id = this.course_actions.set_activity({
535
desc: "Updating project course info...",
536
});
537
try {
538
for (const student of store.get_students().valueSeq().toArray()) {
539
const student_project_id = student.get("project_id");
540
if (student_project_id == null) continue;
541
// account_id: might not be known when student first added, or if student
542
// hasn't joined cocalc yet, so there is no account_id for them.
543
const student_account_id = student.get("account_id");
544
const student_email_address = student.get("email_address"); // will be known if account_id isn't known.
545
await actions.set_project_course_info({
546
project_id: student_project_id,
547
course_project_id: store.get("course_project_id"),
548
path: store.get("course_filename"),
549
pay,
550
payInfo,
551
account_id: student_account_id,
552
email_address: student_email_address,
553
datastore,
554
type: "student",
555
student_project_functionality,
556
envvars,
557
});
558
}
559
} finally {
560
this.course_actions.set_activity({ id });
561
}
562
};
563
564
private configure_project = async (props: {
565
student_id;
566
student_project_id?: string;
567
force_send_invite_by_email?: boolean;
568
license_id?: string; // relevant for serial license strategy only
569
}): Promise<void> => {
570
const { student_id, force_send_invite_by_email, license_id } = props;
571
let student_project_id = props.student_project_id;
572
573
// student_project_id is optional. Will be used instead of from student_id store if provided.
574
// Configure project for the given student so that it has the right title,
575
// description, and collaborators for belonging to the indicated student.
576
// - Add student and collaborators on project containing this course to the new project.
577
// - Hide project from owner/collabs of the project containing the course.
578
// - Set the title to [Student name] + [course title] and description to course description.
579
// console.log("configure_project", student_id);
580
const store = this.get_store();
581
if (student_project_id == null) {
582
student_project_id = store.getIn(["students", student_id, "project_id"]);
583
}
584
// console.log("configure_project", student_id, student_project_id);
585
if (student_project_id == null) {
586
await this.create_student_project(student_id);
587
} else {
588
await Promise.all([
589
this.configure_project_users({
590
student_project_id,
591
student_id,
592
force_send_invite_by_email,
593
}),
594
this.configure_project_visibility(student_project_id),
595
this.configure_project_title(student_project_id, student_id),
596
this.configure_project_description(student_project_id),
597
this.configure_project_compute_image(student_project_id),
598
this.configure_project_envvars(student_project_id),
599
this.configure_project_license(student_project_id, license_id),
600
this.configure_project_envvars(student_project_id),
601
]);
602
}
603
};
604
605
private configure_project_compute_image = async (
606
student_project_id: string,
607
): Promise<void> => {
608
const store = this.get_store();
609
if (store == null) return;
610
const dflt_img = await redux.getStore("customize").getDefaultComputeImage();
611
const img_id = store.get("settings").get("custom_image") ?? dflt_img;
612
const actions = redux.getProjectActions(student_project_id);
613
await actions.set_compute_image(img_id);
614
};
615
616
private configure_project_envvars = async (
617
student_project_id: string,
618
): Promise<void> => {
619
const store = this.get_store();
620
if (!store?.get_envvars()?.inherit) {
621
return;
622
}
623
const env =
624
redux
625
.getStore("projects")
626
.getIn(["project_map", store.get("course_project_id"), "env"])
627
?.toJS() ?? {};
628
const actions = redux.getProjectActions(student_project_id);
629
await actions.set_environment(env);
630
};
631
632
private delete_student_project = async (
633
student_id: string,
634
): Promise<void> => {
635
const store = this.get_store();
636
const student_project_id = store.getIn([
637
"students",
638
student_id,
639
"project_id",
640
]);
641
if (student_project_id == null) return;
642
const student_account_id = store.getIn([
643
"students",
644
student_id,
645
"account_id",
646
]);
647
if (student_account_id != undefined) {
648
redux
649
.getActions("projects")
650
.remove_collaborator(student_project_id, student_account_id);
651
}
652
await redux.getActions("projects").delete_project(student_project_id);
653
this.course_actions.set({
654
create_project: null,
655
project_id: null,
656
table: "students",
657
student_id,
658
});
659
};
660
661
reinvite_oustanding_students = async (): Promise<void> => {
662
const store = this.get_store();
663
if (store == null) return;
664
const id = this.course_actions.set_activity({
665
desc: "Reinviting students...",
666
});
667
try {
668
this.course_actions.setState({ reinviting_students: true });
669
const ids = store.get_student_ids({ deleted: false });
670
if (ids == undefined) return;
671
let i = 0;
672
673
for (const student_id of ids) {
674
if (this.course_actions.is_closed()) return;
675
i += 1;
676
const student = store.get_student(student_id);
677
if (student == null) continue; // weird
678
const student_account_id = student.get("account_id");
679
if (student_account_id != null) continue; // already has an account – no need to reinvite.
680
681
const id1: number = this.course_actions.set_activity({
682
desc: `Progress ${Math.round((100 * i) / ids.length)}%...`,
683
});
684
const last_email_invite = student.get("last_email_invite");
685
if (
686
!last_email_invite ||
687
new Date(last_email_invite) < RESEND_INVITE_BEFORE
688
) {
689
const email_address = student.get("email_address");
690
if (email_address) {
691
await this.invite_student_to_project({
692
student_id,
693
student: email_address,
694
student_project_id: store.get_student_project_id(student_id),
695
});
696
}
697
}
698
this.course_actions.set_activity({ id: id1 });
699
await delay(0); // give UI, etc. a solid chance to render
700
}
701
} catch (err) {
702
this.course_actions.set_error(`Error reinviting students - ${err}`);
703
} finally {
704
if (this.course_actions.is_closed()) return;
705
this.course_actions.setState({ reinviting_students: false });
706
this.course_actions.set_activity({ id });
707
}
708
};
709
710
configure_all_projects = async (force: boolean = false): Promise<void> => {
711
const store = this.get_store();
712
if (store == null) {
713
return;
714
}
715
if (store.get("configuring_projects")) {
716
// currently running already.
717
return;
718
}
719
720
const licenses = await store.getLicenses(force);
721
722
// filter all expired licenses – no point in applying them –
723
// and repeat each license ID as many times as it has seats (run_limit).
724
// that way, licenses will be applied more often if they have more seats.
725
// In particular, we are interested in the case, where a course has way more students than license seats.
726
const allLicenseIDs: string[] = [];
727
// we want to start with the license with the highest run limit
728
const sortedLicenseIDs = sortBy(
729
Object.keys(licenses),
730
(l) => -licenses[l].runLimit,
731
);
732
for (const license_id of sortedLicenseIDs) {
733
const license = licenses[license_id];
734
if (license.expired) continue;
735
for (let i = 0; i < license.runLimit; i++) {
736
allLicenseIDs.push(license_id);
737
}
738
}
739
740
// 2023-03-30: if "serial", then all student projects get exactly one license
741
// and hence all seats are shared between all student projects.
742
const isSerial =
743
store.getIn(["settings", "site_license_strategy"], "serial") == "serial";
744
745
let id: number = -1;
746
try {
747
this.course_actions.setState({ configuring_projects: true });
748
id = this.course_actions.set_activity({
749
desc: "Ensuring all projects are configured...",
750
});
751
const ids = store.get_student_ids({ deleted: false });
752
if (ids == undefined) {
753
return;
754
}
755
let i = 0;
756
757
// Ensure all projects are loaded, rather than just the most recent
758
// n projects -- important since courses often have more than n students!
759
await redux.getActions("projects").load_all_projects();
760
let project_map = redux.getStore("projects").get("project_map");
761
if (project_map == null || webapp_client.account_id == null) {
762
throw Error(
763
"BUG -- project_map must be initialized and you must be signed in; try again later.",
764
);
765
}
766
767
// Make sure we're a collaborator on every student project.
768
let changed = false;
769
for (const student_id of ids) {
770
if (this.course_actions.is_closed()) return;
771
const project_id = store.getIn(["students", student_id, "project_id"]);
772
if (project_id && !project_map.get(project_id)) {
773
await webapp_client.project_collaborators.add_collaborator({
774
account_id: webapp_client.account_id,
775
project_id,
776
});
777
changed = true;
778
}
779
}
780
781
if (changed) {
782
// wait hopefully long enough for info about licenses to be
783
// available in the project_map. This is not 100% bullet proof,
784
// but that is FINE because we only really depend on this to
785
// slightly reduce doing extra work that is unlikely to be a problem.
786
await delay(3000);
787
project_map = redux.getStore("projects").get("project_map");
788
}
789
790
// we make sure no leftover licenses are used by deleted student projects
791
const deletedIDs = store.get_student_ids({ deleted: true });
792
for (const deleted_student_id of deletedIDs) {
793
i += 1;
794
const idDel: number = this.course_actions.set_activity({
795
desc: `Configuring deleted student project ${i} of ${deletedIDs.length}`,
796
});
797
await this.configure_project({
798
student_id: deleted_student_id,
799
student_project_id: undefined,
800
force_send_invite_by_email: false,
801
license_id: "", // no license for a deleted project
802
});
803
this.course_actions.set_activity({ id: idDel });
804
await delay(0); // give UI, etc. a solid chance to render
805
}
806
807
i = 0;
808
for (const student_id of ids) {
809
if (this.course_actions.is_closed()) return;
810
i += 1;
811
const id1: number = this.course_actions.set_activity({
812
desc: `Configuring student project ${i} of ${ids.length}`,
813
});
814
815
// if isSerial is set, we distribute the licenses in "serial" mode:
816
// i.e. we allocate one license per student project in a round-robin fashion
817
// proportional to the number of seats of the license.
818
const license_id: string | undefined = isSerial
819
? allLicenseIDs[i % allLicenseIDs.length]
820
: undefined;
821
822
await this.configure_project({
823
student_id,
824
student_project_id: undefined,
825
force_send_invite_by_email: force,
826
license_id, // if undefined (i.e. !isSerial), all known licenses will be applied to this student project
827
});
828
this.course_actions.set_activity({ id: id1 });
829
await delay(0); // give UI, etc. a solid chance to render
830
}
831
832
// always re-invite students on running this.
833
await this.course_actions.shared_project.configure();
834
await this.set_all_student_project_course_info();
835
} catch (err) {
836
this.course_actions.set_error(
837
`Error configuring student projects - ${err}`,
838
);
839
} finally {
840
if (this.course_actions.is_closed()) return;
841
this.course_actions.setState({ configuring_projects: false });
842
this.course_actions.set_activity({ id });
843
}
844
};
845
846
// Deletes student projects and removes students from those projects
847
deleteAllStudentProjects = async (): Promise<void> => {
848
const store = this.get_store();
849
850
const id = this.course_actions.set_activity({
851
desc: "Deleting all student projects...",
852
});
853
try {
854
const ids = store.get_student_ids({ deleted: false });
855
if (ids == undefined) {
856
return;
857
}
858
for (const student_id of ids) {
859
await this.delete_student_project(student_id);
860
}
861
} catch (err) {
862
this.course_actions.set_error(
863
`error deleting a student project... ${err}`,
864
);
865
} finally {
866
this.course_actions.set_activity({ id });
867
}
868
};
869
870
// upgrade_goal is a map from the quota type to the goal quota the instructor wishes
871
// to get all the students to.
872
upgrade_all_student_projects = async (
873
upgrade_goal: UpgradeGoal,
874
): Promise<void> => {
875
const store = this.get_store();
876
const plan = store.get_upgrade_plan(upgrade_goal);
877
if (len(plan) === 0) {
878
// nothing to do
879
return;
880
}
881
const id = this.course_actions.set_activity({
882
desc: `Adjusting upgrades on ${len(plan)} student projects...`,
883
});
884
const a = redux.getActions("projects");
885
const s = redux.getStore("projects");
886
for (const project_id in plan) {
887
if (project_id == null) continue;
888
const upgrades = plan[project_id];
889
if (upgrades == null) continue;
890
// avoid race if projects are being created *right* when we
891
// try to upgrade them.
892
if (!s.has_project(project_id)) continue;
893
await a.apply_upgrades_to_project(project_id, upgrades, false);
894
}
895
this.course_actions.set_activity({ id });
896
};
897
898
// Do an admin upgrade to all student projects. This changes the base quotas for every student
899
// project as indicated by the quotas object. E.g., to increase the core quota from 1 to 2, do
900
// .admin_upgrade_all_student_projects(cores:2)
901
// The quotas are: cores, cpu_shares, disk_quota, memory, mintime, network, member_host
902
admin_upgrade_all_student_projects = async (quotas): Promise<void> => {
903
const account_store = redux.getStore("account");
904
const groups = account_store.get("groups");
905
if (groups && groups.includes("admin")) {
906
throw Error("must be an admin to upgrade");
907
}
908
const store = this.get_store();
909
const ids: string[] = store.get_student_project_ids();
910
for (const project_id of ids) {
911
const x = copy(quotas);
912
x.project_id = project_id;
913
await webapp_client.project_client.set_quotas(x);
914
}
915
};
916
917
removeFromAllStudentProjects = async (student: StudentRecord) => {
918
/*
919
- Remove student from their project
920
- Remove student from shared project
921
- TODO: Cancel any outstanding invite, in case they haven't even created their account yet.
922
This isn't even implemented yet as an api endpoint... but will cause confusion.
923
*/
924
const shared_id = this.get_store()?.get_shared_project_id();
925
const account_id = student.get("account_id");
926
const project_id = student.get("project_id");
927
if (account_id) {
928
if (project_id) {
929
// remove them from their project
930
await redux
931
.getActions("projects")
932
.remove_collaborator(project_id, account_id);
933
}
934
935
if (shared_id) {
936
// remove them from shared project
937
await redux
938
.getActions("projects")
939
.remove_collaborator(shared_id, account_id);
940
}
941
}
942
};
943
}
944
945