Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/assignments/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 working with assignments:
8
- assigning, collecting, setting feedback, etc.
9
*/
10
11
import { delay, map } from "awaiting";
12
import { Map } from "immutable";
13
import { debounce } from "lodash";
14
import { join } from "path";
15
import { redux } from "@cocalc/frontend/app-framework";
16
import {
17
exec,
18
start_project,
19
stop_project,
20
} from "@cocalc/frontend/frame-editors/generic/client";
21
import {
22
jupyter_strip_notebook,
23
nbgrader,
24
} from "@cocalc/frontend/jupyter/nbgrader/api";
25
import {
26
extract_auto_scores,
27
NotebookScores,
28
} from "@cocalc/frontend/jupyter/nbgrader/autograde";
29
import { ipynb_clear_hidden_tests } from "@cocalc/frontend/jupyter/nbgrader/clear-hidden-tests";
30
import { webapp_client } from "@cocalc/frontend/webapp-client";
31
import {
32
defaults,
33
endswith,
34
len,
35
path_split,
36
peer_grading,
37
split,
38
trunc,
39
uuid,
40
} from "@cocalc/util/misc";
41
import { CourseActions } from "../actions";
42
import { COPY_TIMEOUT_MS } from "../consts";
43
import { export_assignment } from "../export/export-assignment";
44
import { export_student_file_use_times } from "../export/file-use-times";
45
import { grading_state } from "../nbgrader/util";
46
import {
47
AssignmentRecord,
48
CourseStore,
49
get_nbgrader_score,
50
NBgraderRunInfo,
51
} from "../store";
52
import {
53
AssignmentCopyType,
54
copy_type_to_last,
55
LastAssignmentCopyType,
56
SyncDBRecord,
57
SyncDBRecordAssignment,
58
} from "../types";
59
import {
60
assignment_identifier,
61
autograded_filename,
62
previous_step,
63
} from "../util";
64
import {
65
NBGRADER_CELL_TIMEOUT_MS,
66
NBGRADER_MAX_OUTPUT,
67
NBGRADER_MAX_OUTPUT_PER_CELL,
68
NBGRADER_TIMEOUT_MS,
69
PEER_GRADING_GUIDE_FILENAME,
70
PEER_GRADING_GUIDELINES_COMMENT_MARKER,
71
PEER_GRADING_GUIDELINES_GRADE_MARKER,
72
STUDENT_SUBDIR,
73
} from "./consts";
74
import { DUE_DATE_FILENAME } from "../common/consts";
75
76
const UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS = 3000;
77
78
export class AssignmentsActions {
79
private course_actions: CourseActions;
80
81
constructor(course_actions: CourseActions) {
82
this.course_actions = course_actions;
83
}
84
85
private get_store = (): CourseStore => {
86
return this.course_actions.get_store();
87
};
88
89
private collect_path = (path: string): string => {
90
const store = this.get_store();
91
if (store == undefined) {
92
throw Error("store must be defined");
93
}
94
const i = store.get("course_filename").lastIndexOf(".");
95
return store.get("course_filename").slice(0, i) + "-collect/" + path;
96
};
97
98
// slight warning -- this is linear in the number of assignments (so do not overuse)
99
private getAssignmentWithPath = (
100
path: string,
101
): AssignmentRecord | undefined => {
102
const store = this.get_store();
103
if (store == null) return;
104
return store
105
.get("assignments")
106
.valueSeq()
107
.filter((x) => x.get("path") == path)
108
.get(0);
109
};
110
111
addAssignment = async (path: string | string[]): Promise<void> => {
112
// Add one or more assignment to the course, which is defined by giving a directory in the project.
113
// Where we collect homework that students have done (in teacher project).
114
// If the assignment was previously deleted, this undeletes it.
115
if (typeof path != "string") {
116
// handle case of array of inputs
117
for (const p of path) {
118
await this.addAssignment(p);
119
}
120
return;
121
}
122
const cur = this.getAssignmentWithPath(path);
123
if (cur != null) {
124
// either undelete or nothing to do.
125
if (cur.get("deleted")) {
126
// undelete
127
this.undelete_assignment(cur.get("assignment_id"));
128
} else {
129
// nothing to do
130
}
131
return;
132
}
133
134
const collect_path = this.collect_path(path);
135
const path_parts = path_split(path);
136
// folder that we return graded homework to (in student project)
137
const beginning = path_parts.head ? "/graded-" : "graded-";
138
const graded_path = path_parts.head + beginning + path_parts.tail;
139
// folder where we copy the assignment to
140
const target_path = path;
141
142
try {
143
// Ensure the path actually exists.
144
await exec({
145
project_id: this.get_store().get("course_project_id"),
146
command: "mkdir",
147
args: ["-p", path],
148
err_on_exit: true,
149
});
150
} catch (err) {
151
this.course_actions.set_error(`error creating assignment: ${err}`);
152
return;
153
}
154
this.course_actions.set({
155
path,
156
collect_path,
157
graded_path,
158
target_path,
159
table: "assignments",
160
assignment_id: uuid(),
161
});
162
};
163
164
delete_assignment = (assignment_id: string): void => {
165
this.course_actions.set({
166
deleted: true,
167
assignment_id,
168
table: "assignments",
169
});
170
};
171
172
undelete_assignment = (assignment_id: string): void => {
173
this.course_actions.set({
174
deleted: false,
175
assignment_id,
176
table: "assignments",
177
});
178
};
179
180
clear_edited_feedback = (assignment_id: string, student_id: string): void => {
181
const store = this.get_store();
182
let active_feedback_edits = store.get("active_feedback_edits");
183
active_feedback_edits = active_feedback_edits.delete(
184
assignment_identifier(assignment_id, student_id),
185
);
186
this.course_actions.setState({ active_feedback_edits });
187
};
188
189
update_edited_feedback = (assignment_id: string, student_id: string) => {
190
const store = this.get_store();
191
const key = assignment_identifier(assignment_id, student_id);
192
const old_edited_feedback = store.get("active_feedback_edits");
193
const new_edited_feedback = old_edited_feedback.set(key, true);
194
this.course_actions.setState({
195
active_feedback_edits: new_edited_feedback,
196
});
197
};
198
199
// Set a specific grade for a student in an assignment.
200
// This overlaps with save_feedback, but is more
201
// direct and uses that maybe the user isn't manually editing
202
// this. E.g., nbgrader uses this to automatically set the grade.
203
set_grade = (
204
assignment_id: string,
205
student_id: string,
206
grade: string,
207
commit: boolean = true,
208
): void => {
209
const { assignment } = this.course_actions.resolve({
210
assignment_id,
211
});
212
if (assignment == null) {
213
throw Error("no such assignment");
214
}
215
// Annoying that we have to convert to JS here and cast,
216
// but the set below seems to require it.
217
let grades = assignment.get("grades", Map()).toJS() as {
218
[student_id: string]: string;
219
};
220
grades[student_id] = grade;
221
this.course_actions.set(
222
{
223
table: "assignments",
224
assignment_id,
225
grades,
226
},
227
commit,
228
);
229
};
230
231
// Set a specific comment for a student in an assignment.
232
set_comment = (
233
assignment_id: string,
234
student_id: string,
235
comment: string,
236
commit: boolean = true,
237
): void => {
238
const { assignment } = this.course_actions.resolve({
239
assignment_id,
240
});
241
if (assignment == null) {
242
throw Error("no such assignment");
243
}
244
// Annoying that we have to convert to JS here and cast,
245
// but the set below seems to require it.
246
let comments = assignment.get("comments", Map()).toJS() as {
247
[student_id: string]: string;
248
};
249
comments[student_id] = comment;
250
this.course_actions.set(
251
{
252
table: "assignments",
253
assignment_id,
254
comments,
255
},
256
commit,
257
);
258
};
259
260
set_active_assignment_sort = (column_name: string): void => {
261
let is_descending;
262
const store = this.get_store();
263
const current_column = store.getIn([
264
"active_assignment_sort",
265
"column_name",
266
]);
267
if (current_column === column_name) {
268
is_descending = !store.getIn(["active_assignment_sort", "is_descending"]);
269
} else {
270
is_descending = false;
271
}
272
this.course_actions.setState({
273
active_assignment_sort: { column_name, is_descending },
274
});
275
};
276
277
private set_assignment_field = (
278
assignment_id: string,
279
name: string,
280
val,
281
): void => {
282
this.course_actions.set({
283
[name]: val,
284
table: "assignments",
285
assignment_id,
286
});
287
};
288
289
set_due_date = async (
290
assignment_id: string,
291
due_date: Date | string | undefined | null,
292
): Promise<void> => {
293
const { assignment } = this.course_actions.resolve({
294
assignment_id,
295
});
296
if (assignment == null) {
297
return;
298
}
299
const prev_due_date = assignment.get("due_date");
300
301
if (!due_date) {
302
// deleting it
303
if (prev_due_date) {
304
// not deleted so delete it
305
this.set_assignment_field(assignment_id, "due_date", null);
306
this.updateDueDateFile(assignment_id);
307
}
308
return;
309
}
310
311
if (typeof due_date !== "string") {
312
due_date = due_date.toISOString(); // using strings instead of ms for backward compatibility.
313
}
314
315
if (prev_due_date == due_date) {
316
// nothing to do.
317
return;
318
}
319
320
this.set_assignment_field(assignment_id, "due_date", due_date);
321
// it changed, so update the file in all student projects that have already been assigned
322
// https://github.com/sagemathinc/cocalc/issues/2929
323
// NOTE: updateDueDate is debounced, so if set_due_date is called a lot, then the
324
// actual update only happens after it stabilizes for a while. Also, we can be
325
// sure the store has updated the assignment.
326
this.updateDueDateFile(assignment_id);
327
};
328
329
private updateDueDateFile = debounce(async (assignment_id: string) => {
330
// important to check actions due to debounce.
331
if (this.course_actions.is_closed()) return;
332
await this.copy_assignment_create_due_date_file(assignment_id);
333
if (this.course_actions.is_closed()) return;
334
335
const desc = `Copying modified ${DUE_DATE_FILENAME} to all students who have already received it`;
336
const short_desc = `copy ${DUE_DATE_FILENAME}`;
337
338
// by default, doesn't create the due file
339
await this.assignment_action_all_students({
340
assignment_id,
341
old_only: true,
342
action: this.writeDueDateFile,
343
step: "assignment",
344
desc,
345
short_desc,
346
});
347
}, UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS);
348
349
private writeDueDateFile = async (
350
assignment_id: string,
351
student_id: string,
352
) => {
353
const { student, assignment } = this.course_actions.resolve({
354
assignment_id,
355
student_id,
356
});
357
if (!student || !assignment) return;
358
const content = this.dueDateFileContent(assignment_id);
359
const project_id = student.get("project_id");
360
if (!project_id) return;
361
const path = join(assignment.get("target_path"), DUE_DATE_FILENAME);
362
console.log({
363
project_id,
364
path,
365
content,
366
});
367
await webapp_client.project_client.write_text_file({
368
project_id,
369
path,
370
content,
371
});
372
};
373
374
set_assignment_note = (assignment_id: string, note: string): void => {
375
this.set_assignment_field(assignment_id, "note", note);
376
};
377
378
set_peer_grade = (assignment_id: string, config): void => {
379
const store = this.get_store();
380
const a = store.get_assignment(assignment_id);
381
if (a == null) return;
382
let cur: any = a.get("peer_grade");
383
cur = cur == null ? {} : cur.toJS();
384
for (const k in config) {
385
const v = config[k];
386
cur[k] = v;
387
}
388
this.set_assignment_field(assignment_id, "peer_grade", cur);
389
};
390
391
set_skip = (assignment_id: string, step: string, value: boolean): void => {
392
this.set_assignment_field(assignment_id, "skip_" + step, value);
393
};
394
395
// Synchronous function that makes the peer grading map for the given
396
// assignment, if it hasn't already been made.
397
private update_peer_assignment = (assignment_id: string) => {
398
const { store, assignment } = this.course_actions.resolve({
399
assignment_id,
400
});
401
if (!assignment) return;
402
const peers = assignment.getIn(["peer_grade", "map"]);
403
if (peers != null) {
404
return peers.toJS();
405
}
406
const N = assignment.getIn(["peer_grade", "number"], 1);
407
const map = peer_grading(store.get_student_ids(), N);
408
this.set_peer_grade(assignment_id, { map });
409
return map;
410
};
411
412
// Copy the files for the given assignment_id from the given student to the
413
// corresponding collection folder.
414
// If the store is initialized and the student and assignment both exist,
415
// then calling this action will result in this getting set in the store:
416
//
417
// assignment.last_collect[student_id] = {time:?, error:err}
418
//
419
// where time >= now is the current time in milliseconds.
420
private copy_assignment_from_student = async (
421
assignment_id: string,
422
student_id: string,
423
): Promise<void> => {
424
if (this.start_copy(assignment_id, student_id, "last_collect")) {
425
return;
426
}
427
const id = this.course_actions.set_activity({
428
desc: "Copying assignment from a student",
429
});
430
const finish = (err) => {
431
this.course_actions.clear_activity(id);
432
this.finish_copy(assignment_id, student_id, "last_collect", err);
433
if (err) {
434
this.course_actions.set_error(`copy from student: ${err}`);
435
}
436
};
437
const { store, student, assignment } = this.course_actions.resolve({
438
assignment_id,
439
student_id,
440
finish,
441
});
442
if (!student || !assignment) return;
443
const student_name = store.get_student_name(student_id);
444
const student_project_id = student.get("project_id");
445
if (student_project_id == null) {
446
// nothing to do
447
this.course_actions.clear_activity(id);
448
return;
449
}
450
const target_path = join(
451
assignment.get("collect_path"),
452
student.get("student_id"),
453
);
454
this.course_actions.set_activity({
455
id,
456
desc: `Copying assignment from ${student_name}`,
457
});
458
try {
459
await webapp_client.project_client.copy_path_between_projects({
460
src_project_id: student_project_id,
461
src_path: assignment.get("target_path"),
462
target_project_id: store.get("course_project_id"),
463
target_path,
464
overwrite_newer: true,
465
backup: true,
466
delete_missing: false,
467
timeout: COPY_TIMEOUT_MS,
468
});
469
// write their name to a file
470
const name = store.get_student_name_extra(student_id);
471
await this.write_text_file_to_course_project({
472
path: target_path + `/STUDENT - ${name.simple}.txt`,
473
content: `This student is ${name.full}.`,
474
});
475
finish("");
476
} catch (err) {
477
finish(err);
478
}
479
};
480
481
// Copy the graded files for the given assignment_id back to the student in a -graded folder.
482
// If the store is initialized and the student and assignment both exist,
483
// then calling this action will result in this getting set in the store:
484
//
485
// assignment.last_return_graded[student_id] = {time:?, error:err}
486
//
487
// where time >= now is the current time in milliseconds.
488
489
private return_assignment_to_student = async (
490
assignment_id: string,
491
student_id: string,
492
): Promise<void> => {
493
if (this.start_copy(assignment_id, student_id, "last_return_graded")) {
494
return;
495
}
496
const id: number = this.course_actions.set_activity({
497
desc: "Returning assignment to a student",
498
});
499
const finish = (err) => {
500
this.course_actions.clear_activity(id);
501
this.finish_copy(assignment_id, student_id, "last_return_graded", err);
502
if (err) {
503
this.course_actions.set_error(`return to student: ${err}`);
504
}
505
};
506
const { store, student, assignment } = this.course_actions.resolve({
507
assignment_id,
508
student_id,
509
finish,
510
});
511
if (!student || !assignment) return;
512
const grade = store.get_grade(assignment_id, student_id);
513
const comments = store.get_comments(assignment_id, student_id);
514
const student_name = store.get_student_name(student_id);
515
const student_project_id = student.get("project_id");
516
517
// if skip_grading is true, this means there *might* no be a "grade" given,
518
// but instead some grading inside the files or an external tool is used.
519
// therefore, only create the grade file if this is false.
520
const skip_grading = assignment.get("skip_grading", false);
521
522
if (student_project_id == null) {
523
// nothing to do
524
this.course_actions.clear_activity(id);
525
return;
526
}
527
528
let peer_graded;
529
this.course_actions.set_activity({
530
id,
531
desc: `Returning assignment to ${student_name}`,
532
});
533
let src_path = assignment.get("collect_path");
534
if (assignment.getIn(["peer_grade", "enabled"])) {
535
peer_graded = true;
536
src_path += "-peer-grade/";
537
} else {
538
peer_graded = false;
539
}
540
src_path = join(src_path, student.get("student_id"));
541
let content;
542
if (skip_grading && !peer_graded) {
543
content =
544
"Your instructor is doing grading outside CoCalc, or there is no grading for this assignment.";
545
} else {
546
if (grade || peer_graded) {
547
content = "# Your grade";
548
} else {
549
content = "";
550
}
551
}
552
// write their grade to a file
553
if (grade) {
554
// likely undefined when skip_grading true & peer_graded true
555
content += `\n\n${grade}`;
556
}
557
if (comments != null && comments.trim().length > 0) {
558
content += `\n\n# Instructor comments\n\n${comments}`;
559
}
560
if (peer_graded) {
561
content += `\
562
\n\n\n# Peer graded\n\n
563
Your assignment was peer graded by other students.
564
You can find the comments they made above and any directly to your work in the folders below.\
565
`;
566
}
567
568
const nbgrader_scores = store.get_nbgrader_scores(
569
assignment_id,
570
student_id,
571
);
572
const nbgrader_score_ids = store.get_nbgrader_score_ids(assignment_id);
573
if (nbgrader_scores) {
574
const { score, points, error } = get_nbgrader_score(nbgrader_scores);
575
const summary = error ? "error" : `${score}/${points}`;
576
577
let details: string = "";
578
for (const filename in nbgrader_scores) {
579
details += `\n\n**${filename}:**\n\n`;
580
const s = nbgrader_scores[filename];
581
if (typeof s == "string") {
582
details += `ERROR: ${s}\n\n`;
583
} else {
584
details += `| Problem | Score |\n|:----------|:----------|\n`;
585
const ids: string[] = nbgrader_score_ids?.[filename] ?? [];
586
for (const id in s) {
587
if (!ids.includes(id)) {
588
ids.push(id);
589
}
590
}
591
for (const id of ids) {
592
if (s[id] != null) {
593
const t = `${s[id]?.score ?? 0}`;
594
details += `| ${id.padEnd(10)}| ${t.padEnd(10)}|\n`;
595
}
596
}
597
}
598
}
599
600
// TODO: make this nicer, especially the details.
601
content += `\
602
\n\n# nbgrader\n
603
Your notebook was automatically graded using nbgrader, with
604
possible additional instructor tests.
605
606
TOTAL SCORE: ${summary}
607
608
## nbgrader details
609
${details}
610
`;
611
}
612
613
try {
614
await this.write_text_file_to_course_project({
615
path: src_path + "/GRADE.md",
616
content,
617
});
618
await webapp_client.project_client.copy_path_between_projects({
619
src_project_id: store.get("course_project_id"),
620
src_path,
621
target_project_id: student_project_id,
622
target_path: assignment.get("graded_path"),
623
overwrite_newer: true,
624
backup: true,
625
delete_missing: false,
626
exclude: peer_graded ? ["*GRADER*.txt"] : undefined,
627
timeout: COPY_TIMEOUT_MS,
628
});
629
finish("");
630
} catch (err) {
631
finish(err);
632
}
633
};
634
635
// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.
636
return_assignment_to_all_students = async (
637
assignment_id: string,
638
new_only: boolean,
639
): Promise<void> => {
640
const id = this.course_actions.set_activity({
641
desc:
642
"Returning assignments to all students " + new_only
643
? "who have not already received it"
644
: "",
645
});
646
const finish = (err) => {
647
this.course_actions.clear_activity(id);
648
this.course_actions.set_error(`return to student: ${err}`);
649
};
650
const { store, assignment } = this.course_actions.resolve({
651
assignment_id,
652
finish,
653
});
654
if (!assignment) return;
655
let errors: string = "";
656
const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);
657
const skip_grading: boolean = assignment.get("skip_grading", false);
658
const f: (student_id: string) => Promise<void> = async (student_id) => {
659
if (this.course_actions.is_closed()) return;
660
if (
661
!store.last_copied(
662
previous_step("return_graded", peer),
663
assignment_id,
664
student_id,
665
true,
666
)
667
) {
668
// we never collected the assignment from this student
669
return;
670
}
671
const has_grade = store.has_grade(assignment_id, student_id);
672
if (!skip_grading && !has_grade) {
673
// we collected and do grade, but didn't grade it yet
674
return;
675
}
676
if (new_only) {
677
if (
678
store.last_copied("return_graded", assignment_id, student_id, true) &&
679
(skip_grading || has_grade)
680
) {
681
// it was already returned
682
return;
683
}
684
}
685
try {
686
await this.return_assignment_to_student(assignment_id, student_id);
687
} catch (err) {
688
errors += `\n ${err}`;
689
}
690
};
691
692
await map(
693
store.get_student_ids({ deleted: false }),
694
store.get_copy_parallel(),
695
f,
696
);
697
if (errors) {
698
finish(errors);
699
} else {
700
this.course_actions.clear_activity(id);
701
}
702
};
703
704
private finish_copy = (
705
assignment_id: string,
706
student_id: string,
707
type: LastAssignmentCopyType,
708
err: any,
709
): void => {
710
const obj: SyncDBRecord = {
711
table: "assignments",
712
assignment_id,
713
};
714
const a = this.course_actions.get_one(obj);
715
if (a == null) return;
716
const x = a[type] ? a[type] : {};
717
if (err) {
718
x[student_id] = { error: err };
719
} else {
720
x[student_id] = { time: webapp_client.server_time() };
721
}
722
obj[type] = x;
723
this.course_actions.set(obj);
724
};
725
726
// This is called internally before doing any copy/collection operation
727
// to ensure that we aren't doing the same thing repeatedly, and that
728
// everything is in place to do the operation.
729
private start_copy = (
730
assignment_id: string,
731
student_id: string,
732
type: LastAssignmentCopyType,
733
): boolean => {
734
const obj: SyncDBRecordAssignment = {
735
table: "assignments",
736
assignment_id,
737
};
738
const assignment_latest = this.course_actions.get_one(obj);
739
if (assignment_latest == null) return false; // assignment gone
740
let x = assignment_latest[type];
741
if (x == null) x = {};
742
let y = x[student_id];
743
if (y == null) y = {};
744
if (y.start != null && webapp_client.server_time() - y.start <= 15000) {
745
return true; // never retry a copy until at least 15 seconds later.
746
}
747
y.start = webapp_client.server_time();
748
if (y.error) {
749
// clear error when initiating copy
750
y.error = "";
751
}
752
x[student_id] = y;
753
obj[type] = x;
754
this.course_actions.set(obj);
755
return false;
756
};
757
758
private stop_copy = (
759
assignment_id: string,
760
student_id: string,
761
type: LastAssignmentCopyType,
762
): void => {
763
const obj: SyncDBRecordAssignment = {
764
table: "assignments",
765
assignment_id,
766
};
767
const a = this.course_actions.get_one(obj);
768
if (a == null) return;
769
const x = a[type];
770
if (x == null) return;
771
const y = x[student_id];
772
if (y == null) return;
773
if (y.start != null) {
774
delete y.start;
775
x[student_id] = y;
776
obj[type] = x;
777
this.course_actions.set(obj);
778
}
779
};
780
781
// Copy the files for the given assignment to the given student. If
782
// the student project doesn't exist yet, it will be created.
783
// You may also pass in an id for either the assignment or student.
784
// "overwrite" (boolean, optional): if true, the copy operation will overwrite/delete remote files in student projects -- #1483
785
// If the store is initialized and the student and assignment both exist,
786
// then calling this action will result in this getting set in the store:
787
//
788
// assignment.last_assignment[student_id] = {time:?, error:err}
789
//
790
// where time >= now is the current time in milliseconds.
791
private copy_assignment_to_student = async (
792
assignment_id: string,
793
student_id: string,
794
opts: object,
795
): Promise<void> => {
796
const { overwrite, create_due_date_file } = defaults(opts, {
797
overwrite: false,
798
create_due_date_file: false,
799
});
800
const { student, assignment, store } = this.course_actions.resolve({
801
student_id,
802
assignment_id,
803
});
804
if (!student || !assignment) return;
805
if (assignment.get("nbgrader") && !assignment.get("has_student_subdir")) {
806
this.course_actions.set_error(
807
"Assignment contains Jupyter notebooks with nbgrader metadata but there is no student/ subdirectory. The student/ subdirectory gets created when you generate the student version of the assignment. Please generate the student versions of your notebooks (open the notebook, then View --> nbgrader), or remove any nbgrader metadata from them.",
808
);
809
return;
810
}
811
812
if (this.start_copy(assignment_id, student_id, "last_assignment")) {
813
return;
814
}
815
const id = this.course_actions.set_activity({
816
desc: "Copying assignment to a student",
817
});
818
const finish = (err = "") => {
819
this.course_actions.clear_activity(id);
820
this.finish_copy(assignment_id, student_id, "last_assignment", err);
821
if (err) {
822
this.course_actions.set_error(`copy to student: ${err}`);
823
}
824
};
825
826
const student_name = store.get_student_name(student_id);
827
this.course_actions.set_activity({
828
id,
829
desc: `Copying assignment to ${student_name}`,
830
});
831
let student_project_id: string | undefined = student.get("project_id");
832
const src_path = this.assignment_src_path(assignment);
833
try {
834
if (student_project_id == null) {
835
this.course_actions.set_activity({
836
id,
837
desc: `${student_name}'s project doesn't exist, so creating it.`,
838
});
839
student_project_id =
840
await this.course_actions.student_projects.create_student_project(
841
student_id,
842
);
843
if (!student_project_id) {
844
throw Error("failed to create project");
845
}
846
}
847
if (create_due_date_file) {
848
await this.copy_assignment_create_due_date_file(assignment_id);
849
}
850
if (this.course_actions.is_closed()) return;
851
this.course_actions.set_activity({
852
id,
853
desc: `Copying files to ${student_name}'s project`,
854
});
855
const opts = {
856
src_project_id: store.get("course_project_id"),
857
src_path,
858
target_project_id: student_project_id,
859
target_path: assignment.get("target_path"),
860
overwrite_newer: !!overwrite, // default is "false"
861
delete_missing: !!overwrite, // default is "false"
862
backup: !!!overwrite, // default is "true"
863
timeout: COPY_TIMEOUT_MS,
864
};
865
await webapp_client.project_client.copy_path_between_projects(opts);
866
await this.course_actions.compute.setComputeServerAssociations({
867
student_id,
868
src_path: opts.src_path,
869
target_project_id: opts.target_project_id,
870
target_path: opts.target_path,
871
unit_id: assignment_id,
872
});
873
874
// successful finish
875
finish();
876
} catch (err) {
877
// error somewhere along the way
878
finish(err);
879
}
880
};
881
882
private assignment_src_path = (assignment): string => {
883
let path = assignment.get("path");
884
if (assignment.get("has_student_subdir")) {
885
path = join(path, STUDENT_SUBDIR);
886
}
887
return path;
888
};
889
890
// this is part of the assignment disribution, should be done only *once*, not for every student
891
private copy_assignment_create_due_date_file = async (
892
assignment_id: string,
893
): Promise<void> => {
894
const { assignment } = this.course_actions.resolve({
895
assignment_id,
896
});
897
if (!assignment) return;
898
// write the due date to a file
899
const src_path = this.assignment_src_path(assignment);
900
const due_id = this.course_actions.set_activity({
901
desc: `Creating ${DUE_DATE_FILENAME} file...`,
902
});
903
const content = this.dueDateFileContent(assignment_id);
904
const path = join(src_path, DUE_DATE_FILENAME);
905
906
try {
907
await this.write_text_file_to_course_project({
908
path,
909
content,
910
});
911
} catch (err) {
912
throw Error(
913
`Problem writing ${DUE_DATE_FILENAME} file ('${err}'). Try again...`,
914
);
915
} finally {
916
this.course_actions.clear_activity(due_id);
917
}
918
};
919
920
private dueDateFileContent = (assignment_id) => {
921
const due_date = this.get_store()?.get_due_date(assignment_id);
922
if (due_date) {
923
return `This assignment is due\n\n ${due_date.toLocaleString()}`;
924
} else {
925
return "No due date has been set.";
926
}
927
};
928
929
copy_assignment = async (
930
type: AssignmentCopyType,
931
assignment_id: string,
932
student_id: string,
933
): Promise<void> => {
934
// type = assigned, collected, graded, peer-assigned, peer-collected
935
switch (type) {
936
case "assigned":
937
// make sure listing is up to date, since it sets "has_student_subdir",
938
// which impacts the distribute semantics.
939
await this.update_listing(assignment_id);
940
await this.copy_assignment_to_student(assignment_id, student_id, {
941
create_due_date_file: true,
942
});
943
return;
944
case "collected":
945
await this.copy_assignment_from_student(assignment_id, student_id);
946
return;
947
case "graded":
948
await this.return_assignment_to_student(assignment_id, student_id);
949
return;
950
case "peer-assigned":
951
await this.peer_copy_to_student(assignment_id, student_id);
952
return;
953
case "peer-collected":
954
await this.peer_collect_from_student(assignment_id, student_id);
955
return;
956
default:
957
this.course_actions.set_error(
958
`copy_assignment -- unknown type: ${type}`,
959
);
960
return;
961
}
962
};
963
964
// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.
965
copy_assignment_to_all_students = async (
966
assignment_id: string,
967
new_only: boolean,
968
overwrite: boolean,
969
): Promise<void> => {
970
const desc = `Copying assignments to all students ${
971
new_only ? "who have not already received it" : ""
972
}`;
973
const short_desc = "copy to student";
974
await this.update_listing(assignment_id); // make sure this is up to date
975
if (this.course_actions.is_closed()) return;
976
await this.copy_assignment_create_due_date_file(assignment_id);
977
if (this.course_actions.is_closed()) return;
978
// by default, doesn't create the due file
979
await this.assignment_action_all_students({
980
assignment_id,
981
new_only,
982
action: this.copy_assignment_to_student,
983
step: "assignment",
984
desc,
985
short_desc,
986
overwrite,
987
});
988
};
989
990
// Copy the given assignment to from all non-deleted students, doing several copies in parallel at once.
991
copy_assignment_from_all_students = async (
992
assignment_id: string,
993
new_only: boolean,
994
): Promise<void> => {
995
let desc = "Copying assignment from all students";
996
if (new_only) {
997
desc += " from whom we have not already copied it";
998
}
999
const short_desc = "copy from student";
1000
await this.assignment_action_all_students({
1001
assignment_id,
1002
new_only,
1003
action: this.copy_assignment_from_student,
1004
step: "collect",
1005
desc,
1006
short_desc,
1007
});
1008
};
1009
1010
private start_all_for_peer_grading = async (): Promise<void> => {
1011
// On cocalc.com, if the student projects get started specifically
1012
// for the purposes of copying files to/from them, then they stop
1013
// around a minute later. This is very bad for peer grading, since
1014
// so much copying occurs, and we end up with conflicts between
1015
// projects starting to peer grade, then stop, then needing to be
1016
// started again all at once. We thus request that they all start,
1017
// wait a few seconds for that "reason" for them to be running to
1018
// take effect, and then do the copy. This way the projects aren't
1019
// automatically stopped after the copies happen.
1020
const id = this.course_actions.set_activity({
1021
desc: "Warming up all student projects for peer grading...",
1022
});
1023
this.course_actions.student_projects.action_all_student_projects("start");
1024
// We request to start all projects simultaneously, and the system
1025
// will start doing that. I think it's not so much important that
1026
// the projects are actually running, but that they were started
1027
// before the copy operations started.
1028
await delay(5 * 1000);
1029
this.course_actions.clear_activity(id);
1030
};
1031
1032
async peer_copy_to_all_students(
1033
assignment_id: string,
1034
new_only: boolean,
1035
): Promise<void> {
1036
let desc = "Copying assignments for peer grading to all students ";
1037
if (new_only) {
1038
desc += " who have not already received their copy";
1039
}
1040
const short_desc = "copy to student for peer grading";
1041
// CRITICAL: be sure to run this update once before doing the
1042
// assignment. Otherwise, since assignment runs more than once
1043
// in parallel, two will launch at about the same time and
1044
// the *condition* to know if it is done depends on the store,
1045
// which defers when it gets updated. Anyway, this line is critical:
1046
try {
1047
this.update_peer_assignment(assignment_id);
1048
} catch (err) {
1049
this.course_actions.set_error(`${short_desc} -- ${err}`);
1050
return;
1051
}
1052
await this.start_all_for_peer_grading();
1053
// OK, now do the assignment... in parallel.
1054
await this.assignment_action_all_students({
1055
assignment_id,
1056
new_only,
1057
action: this.peer_copy_to_student,
1058
step: "peer_assignment",
1059
desc,
1060
short_desc,
1061
});
1062
}
1063
1064
async peer_collect_from_all_students(
1065
assignment_id: string,
1066
new_only: boolean,
1067
): Promise<void> {
1068
let desc = "Copying peer graded assignments from all students";
1069
if (new_only) {
1070
desc += " from whom we have not already copied it";
1071
}
1072
const short_desc = "copy peer grading from students";
1073
await this.start_all_for_peer_grading();
1074
await this.assignment_action_all_students({
1075
assignment_id,
1076
new_only,
1077
action: this.peer_collect_from_student,
1078
step: "peer_collect",
1079
desc,
1080
short_desc,
1081
});
1082
await this.peerParseStudentGrading(assignment_id);
1083
}
1084
1085
private peerParseStudentGrading = async (assignment_id: string) => {
1086
// For each student do the following:
1087
// If they already have a recorded grade, do nothing further.
1088
// If they do not have a recorded grade, load all of the
1089
// PEER_GRADING_GUIDE_FILENAME files that were collected
1090
// from the students, then create a grade from that (if possible), along
1091
// with a comment that explains how that grade was obtained, without
1092
// saying which student did what.
1093
const { store, assignment } = this.course_actions.resolve({
1094
assignment_id,
1095
});
1096
if (assignment == null) {
1097
throw Error("no such assignment");
1098
}
1099
const id = this.course_actions.set_activity({
1100
desc: "Parsing peer grading",
1101
});
1102
const allGrades = assignment.get("grades", Map()).toJS() as {
1103
[student_id: string]: string;
1104
};
1105
const allComments = assignment.get("comments", Map()).toJS() as {
1106
[student_id: string]: string;
1107
};
1108
// compute missing grades
1109
for (const student_id of store.get_student_ids()) {
1110
if (allGrades[student_id]) {
1111
// a grade is already set
1112
continue;
1113
}
1114
// attempt to compute a grade
1115
const peer_student_ids: string[] = store.get_peers_that_graded_student(
1116
assignment_id,
1117
student_id,
1118
);
1119
const course_project_id = store.get("course_project_id");
1120
const grades: number[] = [];
1121
let comments: string[] = [];
1122
const student_name = store.get_student_name(student_id);
1123
this.course_actions.set_activity({
1124
id,
1125
desc: `Parsing peer grading of ${student_name}`,
1126
});
1127
for (const peer_student_id of peer_student_ids) {
1128
const path = join(
1129
`${assignment.get("collect_path")}-peer-grade`,
1130
student_id,
1131
peer_student_id,
1132
PEER_GRADING_GUIDE_FILENAME,
1133
);
1134
try {
1135
const contents = await webapp_client.project_client.read_text_file({
1136
project_id: course_project_id,
1137
path,
1138
});
1139
const i = contents.lastIndexOf(PEER_GRADING_GUIDELINES_GRADE_MARKER);
1140
if (i == -1) {
1141
continue;
1142
}
1143
let j = contents.lastIndexOf(PEER_GRADING_GUIDELINES_COMMENT_MARKER);
1144
if (j == -1) {
1145
j = contents.length;
1146
}
1147
const grade = parseFloat(
1148
contents
1149
.slice(i + PEER_GRADING_GUIDELINES_GRADE_MARKER.length, j)
1150
.trim(),
1151
);
1152
if (!isFinite(grade) && isNaN(grade)) {
1153
continue;
1154
}
1155
const comment = contents.slice(
1156
j + PEER_GRADING_GUIDELINES_COMMENT_MARKER.length,
1157
);
1158
grades.push(grade);
1159
comments.push(comment);
1160
} catch (err) {
1161
// grade not available for some reason
1162
console.warn("issue reading peer grading file", {
1163
path,
1164
err,
1165
student_name,
1166
});
1167
}
1168
}
1169
if (grades.length > 0) {
1170
const grade = grades.reduce((a, b) => a + b) / grades.length;
1171
allGrades[student_id] = `${grade}`;
1172
if (!allComments[student_id]) {
1173
const studentComments = comments
1174
.filter((x) => x.trim())
1175
.map((x) => `- ${x.trim()}`)
1176
.join("\n\n");
1177
allComments[student_id] = `Grades: ${grades.join(", ")}\n\n${
1178
studentComments ? "Student Comments:\n" + studentComments : ""
1179
}`;
1180
}
1181
}
1182
}
1183
// set them in the course data
1184
this.course_actions.set(
1185
{
1186
table: "assignments",
1187
assignment_id,
1188
grades: allGrades,
1189
comments: allComments,
1190
},
1191
true,
1192
);
1193
this.course_actions.clear_activity(id);
1194
};
1195
1196
private assignment_action_all_students = async ({
1197
assignment_id,
1198
new_only,
1199
old_only,
1200
action,
1201
step,
1202
desc,
1203
short_desc,
1204
overwrite,
1205
}: {
1206
assignment_id: string;
1207
// only do the action when it hasn't been done already
1208
new_only?: boolean;
1209
// only do the action when it HAS been done already
1210
old_only?: boolean;
1211
action: (
1212
assignment_id: string,
1213
student_id: string,
1214
opts: any,
1215
) => Promise<void>;
1216
step;
1217
desc;
1218
short_desc: string;
1219
overwrite?: boolean;
1220
}): Promise<void> => {
1221
if (new_only && old_only) {
1222
// no matter what, this means the empty set, so nothing to do.
1223
// Of course no code shouild actually call this.
1224
return;
1225
}
1226
const id = this.course_actions.set_activity({ desc });
1227
const finish = (err) => {
1228
this.course_actions.clear_activity(id);
1229
err = `${short_desc}: ${err}`;
1230
this.course_actions.set_error(err);
1231
};
1232
const { store, assignment } = this.course_actions.resolve({
1233
assignment_id,
1234
finish,
1235
});
1236
if (!assignment) return;
1237
let errors = "";
1238
const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);
1239
const prev_step =
1240
step == "assignment" ? undefined : previous_step(step, peer);
1241
const f = async (student_id: string): Promise<void> => {
1242
if (this.course_actions.is_closed()) return;
1243
const store = this.get_store();
1244
if (
1245
prev_step != null &&
1246
!store.last_copied(prev_step, assignment_id, student_id, true)
1247
) {
1248
return;
1249
}
1250
const alreadyCopied = !!store.last_copied(
1251
step,
1252
assignment_id,
1253
student_id,
1254
true,
1255
);
1256
if (new_only && alreadyCopied) {
1257
// only for the ones that haven't already been copied
1258
return;
1259
}
1260
if (old_only && !alreadyCopied) {
1261
// only for the ones that *HAVE* already been copied.
1262
return;
1263
}
1264
try {
1265
await action(assignment_id, student_id, { overwrite });
1266
} catch (err) {
1267
errors += `\n ${err}`;
1268
}
1269
};
1270
1271
await map(
1272
store.get_student_ids({ deleted: false }),
1273
store.get_copy_parallel(),
1274
f,
1275
);
1276
1277
if (errors) {
1278
finish(errors);
1279
} else {
1280
this.course_actions.clear_activity(id);
1281
}
1282
};
1283
1284
// Copy the collected folders from some students to the given student for peer grading.
1285
// Assumes folder is non-empty
1286
private peer_copy_to_student = async (
1287
assignment_id: string,
1288
student_id: string,
1289
): Promise<void> => {
1290
if (this.start_copy(assignment_id, student_id, "last_peer_assignment")) {
1291
return;
1292
}
1293
const id = this.course_actions.set_activity({
1294
desc: "Copying peer grading to a student",
1295
});
1296
const finish = (err?) => {
1297
this.course_actions.clear_activity(id);
1298
this.finish_copy(assignment_id, student_id, "last_peer_assignment", err);
1299
if (err) {
1300
this.course_actions.set_error(`copy peer-grading to student: ${err}`);
1301
}
1302
};
1303
const { store, student, assignment } = this.course_actions.resolve({
1304
assignment_id,
1305
student_id,
1306
finish,
1307
});
1308
if (!student || !assignment) return;
1309
1310
const student_name = store.get_student_name(student_id);
1311
this.course_actions.set_activity({
1312
id,
1313
desc: `Copying peer grading to ${student_name}`,
1314
});
1315
1316
let peer_map;
1317
try {
1318
// synchronous, but could fail, e.g., not enough students
1319
peer_map = this.update_peer_assignment(assignment_id);
1320
} catch (err) {
1321
this.course_actions.set_error(`peer copy to student: ${err}`);
1322
finish();
1323
return;
1324
}
1325
1326
if (peer_map == null) {
1327
finish();
1328
return;
1329
}
1330
1331
const peers = peer_map[student.get("student_id")];
1332
if (peers == null) {
1333
// empty peer assignment for this student (maybe student added after
1334
// peer assignment already created?)
1335
finish();
1336
return;
1337
}
1338
1339
const student_project_id = student.get("project_id");
1340
if (!student_project_id) {
1341
finish();
1342
return;
1343
}
1344
1345
let guidelines: string = assignment.getIn(
1346
["peer_grade", "guidelines"],
1347
"Please grade this assignment.",
1348
);
1349
const due_date = assignment.getIn(["peer_grade", "due_date"]);
1350
if (due_date != null) {
1351
guidelines =
1352
`GRADING IS DUE ${new Date(due_date).toLocaleString()} \n\n ` +
1353
guidelines;
1354
}
1355
1356
const target_base_path = assignment.get("path") + "-peer-grade";
1357
const f = async (peer_student_id: string) => {
1358
if (this.course_actions.is_closed()) {
1359
return;
1360
}
1361
const src_path = join(assignment.get("collect_path"), peer_student_id);
1362
// write instructions file for the student, where they enter the grade,
1363
// and also it tells them what to do.
1364
await this.write_text_file_to_course_project({
1365
path: join(src_path, PEER_GRADING_GUIDE_FILENAME),
1366
content: guidelines,
1367
});
1368
const target_path = join(target_base_path, peer_student_id);
1369
// In the copy below, we exclude the student's name so that
1370
// peer grading is anonymous; also, remove original
1371
// due date to avoid confusion.
1372
// copy the files to be peer graded into place for this student
1373
await webapp_client.project_client.copy_path_between_projects({
1374
src_project_id: store.get("course_project_id"),
1375
src_path,
1376
target_project_id: student_project_id,
1377
target_path,
1378
overwrite_newer: false,
1379
delete_missing: false,
1380
exclude: ["*STUDENT*.txt", "*" + DUE_DATE_FILENAME + "*"],
1381
timeout: COPY_TIMEOUT_MS,
1382
});
1383
};
1384
1385
try {
1386
// now copy actual stuff to grade
1387
await map(peers, store.get_copy_parallel(), f);
1388
finish();
1389
} catch (err) {
1390
finish(err);
1391
return;
1392
}
1393
};
1394
1395
// Collect all the peer graading of the given student (not the work the student did, but
1396
// the grading about the student!).
1397
private peer_collect_from_student = async (
1398
assignment_id: string,
1399
student_id: string,
1400
): Promise<void> => {
1401
if (this.start_copy(assignment_id, student_id, "last_peer_collect")) {
1402
return;
1403
}
1404
const id = this.course_actions.set_activity({
1405
desc: "Collecting peer grading of a student",
1406
});
1407
const finish = (err?) => {
1408
this.course_actions.clear_activity(id);
1409
this.finish_copy(assignment_id, student_id, "last_peer_collect", err);
1410
if (err) {
1411
this.course_actions.set_error(
1412
`collecting peer-grading of a student: ${err}`,
1413
);
1414
}
1415
};
1416
1417
const { store, student, assignment } = this.course_actions.resolve({
1418
student_id,
1419
assignment_id,
1420
finish,
1421
});
1422
if (!student || !assignment) return;
1423
1424
const student_name = store.get_student_name(student_id);
1425
this.course_actions.set_activity({
1426
id,
1427
desc: `Collecting peer grading of ${student_name}`,
1428
});
1429
1430
// list of student_id of students that graded this student (may be empty)
1431
const peers: string[] = store.get_peers_that_graded_student(
1432
assignment_id,
1433
student_id,
1434
);
1435
1436
const our_student_id = student.get("student_id");
1437
1438
const f = async (student_id: string): Promise<void> => {
1439
const s = store.get_student(student_id);
1440
// ignore deleted or non-existent students
1441
if (s == null || s.get("deleted")) return;
1442
1443
const path = assignment.get("path");
1444
const src_path = join(`${path}-peer-grade`, our_student_id);
1445
const target_path = join(
1446
`${assignment.get("collect_path")}-peer-grade`,
1447
our_student_id,
1448
student_id,
1449
);
1450
1451
const src_project_id = s.get("project_id");
1452
if (!src_project_id) {
1453
return;
1454
}
1455
1456
// copy the files over from the student who did the peer grading
1457
await webapp_client.project_client.copy_path_between_projects({
1458
src_project_id,
1459
src_path,
1460
target_project_id: store.get("course_project_id"),
1461
target_path,
1462
overwrite_newer: false,
1463
delete_missing: false,
1464
timeout: COPY_TIMEOUT_MS,
1465
});
1466
1467
// write local file identifying the grader
1468
let name = store.get_student_name_extra(student_id);
1469
await this.write_text_file_to_course_project({
1470
path: target_path + `/GRADER - ${name.simple}.txt`,
1471
content: `The student who did the peer grading is named ${name.full}.`,
1472
});
1473
1474
// write local file identifying student being graded
1475
name = store.get_student_name_extra(our_student_id);
1476
await this.write_text_file_to_course_project({
1477
path: target_path + `/STUDENT - ${name.simple}.txt`,
1478
content: `This student is ${name.full}.`,
1479
});
1480
};
1481
1482
try {
1483
await map(peers, store.get_copy_parallel(), f);
1484
finish();
1485
} catch (err) {
1486
finish(err);
1487
}
1488
};
1489
1490
// This doesn't really stop it yet, since that's not supported by the backend.
1491
// It does stop the spinner and let the user try to restart the copy.
1492
stop_copying_assignment = (
1493
assignment_id: string,
1494
student_id: string,
1495
type: AssignmentCopyType,
1496
): void => {
1497
this.stop_copy(assignment_id, student_id, copy_type_to_last(type));
1498
};
1499
1500
open_assignment = (
1501
type: AssignmentCopyType,
1502
assignment_id: string,
1503
student_id: string,
1504
): void => {
1505
const { store, assignment, student } = this.course_actions.resolve({
1506
assignment_id,
1507
student_id,
1508
});
1509
if (assignment == null || student == null) return;
1510
const student_project_id = student.get("project_id");
1511
if (student_project_id == null) {
1512
this.course_actions.set_error(
1513
"open_assignment: student project not yet created",
1514
);
1515
return;
1516
}
1517
// Figure out what to open
1518
let path, proj;
1519
switch (type) {
1520
case "assigned": // where project was copied in the student's project.
1521
path = assignment.get("target_path");
1522
proj = student_project_id;
1523
break;
1524
case "collected": // where collected locally
1525
path = join(assignment.get("collect_path"), student.get("student_id")); // TODO: refactor
1526
proj = store.get("course_project_id");
1527
break;
1528
case "peer-assigned": // where peer-assigned (in student's project)
1529
proj = student_project_id;
1530
path = assignment.get("path") + "-peer-grade";
1531
break;
1532
case "peer-collected": // where collected peer-graded work (in our project)
1533
path =
1534
assignment.get("collect_path") +
1535
"-peer-grade/" +
1536
student.get("student_id");
1537
proj = store.get("course_project_id");
1538
break;
1539
case "graded": // where project returned
1540
path = assignment.get("graded_path"); // refactor
1541
proj = student_project_id;
1542
break;
1543
default:
1544
this.course_actions.set_error(
1545
`open_assignment -- unknown type: ${type}`,
1546
);
1547
}
1548
if (proj == null) {
1549
this.course_actions.set_error("no such project");
1550
return;
1551
}
1552
// Now open it
1553
redux.getProjectActions(proj).open_directory(path);
1554
};
1555
1556
private write_text_file_to_course_project = async (opts: {
1557
path: string;
1558
content: string;
1559
}): Promise<void> => {
1560
await webapp_client.project_client.write_text_file({
1561
project_id: this.get_store().get("course_project_id"),
1562
path: opts.path,
1563
content: opts.content,
1564
});
1565
};
1566
1567
// Update datastore with directory listing of non-hidden content of the assignment.
1568
// This also sets whether or not there is a STUDENT_SUBDIR directory.
1569
update_listing = async (assignment_id: string): Promise<void> => {
1570
const { store, assignment } = this.course_actions.resolve({
1571
assignment_id,
1572
});
1573
if (assignment == null) return;
1574
const project_id = store.get("course_project_id");
1575
const path = assignment.get("path");
1576
if (project_id == null || path == null) return;
1577
let listing;
1578
try {
1579
const { files } = await webapp_client.project_client.directory_listing({
1580
project_id,
1581
path,
1582
hidden: false,
1583
compute_server_id: 0, // TODO
1584
});
1585
listing = files;
1586
} catch (err) {
1587
// This might happen, e.g., if the assignment directory is deleted or user messes
1588
// with permissions...
1589
// In this case, just give up.
1590
return;
1591
}
1592
if (listing == null || this.course_actions.is_closed()) return;
1593
this.course_actions.set({
1594
listing,
1595
assignment_id,
1596
table: "assignments",
1597
});
1598
1599
let has_student_subdir: boolean = false;
1600
for (const entry of listing) {
1601
if (entry.isdir && entry.name == STUDENT_SUBDIR) {
1602
has_student_subdir = true;
1603
break;
1604
}
1605
}
1606
const nbgrader = await this.has_nbgrader_metadata(assignment_id);
1607
if (this.course_actions.is_closed()) return;
1608
this.course_actions.set({
1609
has_student_subdir,
1610
nbgrader,
1611
assignment_id,
1612
table: "assignments",
1613
});
1614
};
1615
1616
/* Scan all Jupyter notebooks in the top level of either the assignment directory or
1617
the student/
1618
subdirectory of it for cells with nbgrader metadata. If any are found, return
1619
true; otherwise, return false.
1620
*/
1621
private has_nbgrader_metadata = async (
1622
assignment_id: string,
1623
): Promise<boolean> => {
1624
return len(await this.nbgrader_instructor_ipynb_files(assignment_id)) > 0;
1625
};
1626
1627
// Read in the (stripped) contents of all nbgrader instructor ipynb
1628
// files for this assignment. These are:
1629
// - Every ipynb file in the assignment directory that has a cell that
1630
// contains nbgrader metadata (and isn't mangled).
1631
private nbgrader_instructor_ipynb_files = async (
1632
assignment_id: string,
1633
): Promise<{ [path: string]: string }> => {
1634
const { store, assignment } = this.course_actions.resolve({
1635
assignment_id,
1636
});
1637
if (assignment == null) {
1638
return {}; // nothing case.
1639
}
1640
const path = assignment.get("path");
1641
const project_id = store.get("course_project_id");
1642
let files;
1643
try {
1644
files = await redux
1645
.getProjectStore(project_id)
1646
.get_listings()
1647
.getListingDirectly(path);
1648
} catch (err) {
1649
// This happens, e.g., if the instructor moves the directory
1650
// that contains their version of the ipynb file.
1651
// See https://github.com/sagemathinc/cocalc/issues/5501
1652
const error = `Unable to find the directory where you created this assignment. If you moved or renamed it, please move or copy it back to "${path}", then try again. (${err})`;
1653
this.course_actions.set_error(error);
1654
throw err;
1655
}
1656
const result: { [path: string]: string } = {};
1657
1658
if (this.course_actions.is_closed()) return result;
1659
1660
const to_read = files
1661
.filter((entry) => !entry.isdir && endswith(entry.name, ".ipynb"))
1662
.map((entry) => entry.name);
1663
1664
const f: (file: string) => Promise<void> = async (file) => {
1665
if (this.course_actions.is_closed()) return;
1666
const fullpath = path != "" ? join(path, file) : file;
1667
try {
1668
const content = await jupyter_strip_notebook(project_id, fullpath);
1669
const { cells } = JSON.parse(content);
1670
for (const cell of cells) {
1671
if (cell.metadata.nbgrader) {
1672
result[file] = content;
1673
return;
1674
}
1675
}
1676
} catch (err) {
1677
return;
1678
}
1679
};
1680
1681
await map(to_read, 10, f);
1682
return result;
1683
};
1684
1685
// Run nbgrader for all students for which this assignment
1686
// has been collected at least once.
1687
run_nbgrader_for_all_students = async (
1688
assignment_id: string,
1689
ungraded_only?: boolean,
1690
): Promise<void> => {
1691
// console.log("run_nbgrader_for_all_students", assignment_id);
1692
const instructor_ipynb_files =
1693
await this.nbgrader_instructor_ipynb_files(assignment_id);
1694
if (this.course_actions.is_closed()) return;
1695
const store = this.get_store();
1696
const nbgrader_scores = store.getIn([
1697
"assignments",
1698
assignment_id,
1699
"nbgrader_scores",
1700
]);
1701
const one_student: (student_id: string) => Promise<void> = async (
1702
student_id,
1703
) => {
1704
if (this.course_actions.is_closed()) return;
1705
if (!store.last_copied("collect", assignment_id, student_id, true)) {
1706
// Do not try to grade the assignment, since it wasn't
1707
// already successfully collected yet.
1708
return;
1709
}
1710
if (
1711
ungraded_only &&
1712
grading_state(student_id, nbgrader_scores) == "succeeded"
1713
) {
1714
// Do not try to grade assignment, if it has already been successfully graded.
1715
return;
1716
}
1717
await this.run_nbgrader_for_one_student(
1718
assignment_id,
1719
student_id,
1720
instructor_ipynb_files,
1721
true,
1722
);
1723
};
1724
try {
1725
this.nbgrader_set_is_running(assignment_id);
1726
await map(
1727
this.get_store().get_student_ids({ deleted: false }),
1728
this.get_store().get_nbgrader_parallel(),
1729
one_student,
1730
);
1731
this.course_actions.syncdb.commit();
1732
} finally {
1733
this.nbgrader_set_is_done(assignment_id);
1734
}
1735
};
1736
1737
set_nbgrader_scores_for_all_students = ({
1738
assignment_id,
1739
force,
1740
commit,
1741
}: {
1742
assignment_id: string;
1743
force?: boolean;
1744
commit?: boolean;
1745
}): void => {
1746
for (const student_id of this.get_store().get_student_ids({
1747
deleted: false,
1748
})) {
1749
this.set_grade_using_nbgrader_if_possible(
1750
assignment_id,
1751
student_id,
1752
false,
1753
force,
1754
);
1755
}
1756
if (commit) {
1757
this.course_actions.syncdb.commit();
1758
}
1759
};
1760
1761
set_nbgrader_scores_for_one_student = (
1762
assignment_id: string,
1763
student_id: string,
1764
scores: { [filename: string]: NotebookScores | string },
1765
nbgrader_score_ids:
1766
| { [filename: string]: string[] }
1767
| undefined = undefined,
1768
commit: boolean = true,
1769
): void => {
1770
const assignment_data = this.course_actions.get_one({
1771
table: "assignments",
1772
assignment_id,
1773
});
1774
if (assignment_data == null) return;
1775
const nbgrader_scores: {
1776
[student_id: string]: { [ipynb: string]: NotebookScores | string };
1777
} = assignment_data.nbgrader_scores || {};
1778
nbgrader_scores[student_id] = scores;
1779
this.course_actions.set(
1780
{
1781
table: "assignments",
1782
assignment_id,
1783
nbgrader_scores,
1784
...(nbgrader_score_ids != null ? { nbgrader_score_ids } : undefined),
1785
},
1786
commit,
1787
);
1788
this.set_grade_using_nbgrader_if_possible(
1789
assignment_id,
1790
student_id,
1791
commit,
1792
);
1793
};
1794
1795
set_specific_nbgrader_score = (
1796
assignment_id: string,
1797
student_id: string,
1798
filename: string,
1799
grade_id: string,
1800
score: number,
1801
commit: boolean = true,
1802
): void => {
1803
const { assignment } = this.course_actions.resolve({
1804
assignment_id,
1805
});
1806
if (assignment == null) {
1807
throw Error("no such assignment");
1808
}
1809
1810
const scores: any = assignment
1811
.getIn(["nbgrader_scores", student_id], Map())
1812
.toJS();
1813
let x: any = scores[filename];
1814
if (x == null) {
1815
x = scores[filename] = {};
1816
}
1817
let y = x[grade_id];
1818
if (y == null) {
1819
y = x[grade_id] = {};
1820
}
1821
y.score = score;
1822
if (y.points != null && y.score > y.points) {
1823
y.score = y.points;
1824
}
1825
if (y.score < 0) {
1826
y.score = 0;
1827
}
1828
this.set_nbgrader_scores_for_one_student(
1829
assignment_id,
1830
student_id,
1831
scores,
1832
undefined,
1833
commit,
1834
);
1835
1836
this.set_grade_using_nbgrader_if_possible(
1837
assignment_id,
1838
student_id,
1839
commit,
1840
);
1841
};
1842
1843
// Fill in manual grade if it is blank and there is an nbgrader grade
1844
// and all the manual nbgrader scores have been filled in.
1845
// Also, the filled in grade uses a specific format [number]/[total]
1846
// and if this is maintained and the nbgrader scores change, this
1847
// the manual grade is updated.
1848
set_grade_using_nbgrader_if_possible = (
1849
assignment_id: string,
1850
student_id: string,
1851
commit: boolean = true,
1852
force: boolean = false,
1853
): void => {
1854
// Check if nbgrader scores are all available.
1855
const store = this.get_store();
1856
const scores = store.get_nbgrader_scores(assignment_id, student_id);
1857
if (scores == null) {
1858
// no info -- maybe nbgrader not even run yet.
1859
return;
1860
}
1861
const { score, points, error, manual_needed } = get_nbgrader_score(scores);
1862
if (!force && (error || manual_needed)) {
1863
// more work must be done before we can use this.
1864
return;
1865
}
1866
1867
// Fill in the overall grade if either it is currently unset, blank,
1868
// or of the form [number]/[number].
1869
const grade = store.get_grade(assignment_id, student_id).trim();
1870
if (force || grade == "" || grade.match(/\d+\/\d+/g)) {
1871
this.set_grade(assignment_id, student_id, `${score}/${points}`, commit);
1872
}
1873
};
1874
1875
run_nbgrader_for_one_student = async (
1876
assignment_id: string,
1877
student_id: string,
1878
instructor_ipynb_files?: { [path: string]: string },
1879
commit: boolean = true,
1880
): Promise<void> => {
1881
// console.log("run_nbgrader_for_one_student", assignment_id, student_id);
1882
1883
const { store, assignment, student } = this.course_actions.resolve({
1884
assignment_id,
1885
student_id,
1886
});
1887
1888
if (
1889
student == null ||
1890
assignment == null ||
1891
!assignment.get("has_student_subdir")
1892
) {
1893
return; // nothing case.
1894
}
1895
1896
const nbgrader_grade_project: string | undefined = store.getIn([
1897
"settings",
1898
"nbgrader_grade_project",
1899
]);
1900
1901
const nbgrader_include_hidden_tests: boolean = !!store.getIn([
1902
"settings",
1903
"nbgrader_include_hidden_tests",
1904
]);
1905
1906
const course_project_id = store.get("course_project_id");
1907
const student_project_id = student.get("project_id");
1908
1909
let grade_project_id: string;
1910
let student_path: string;
1911
let stop_student_project = false;
1912
if (nbgrader_grade_project) {
1913
grade_project_id = nbgrader_grade_project;
1914
1915
// grade in the path where we collected their work.
1916
student_path = join(
1917
assignment.get("collect_path"),
1918
student.get("student_id"),
1919
);
1920
1921
this.course_actions.configuration.configure_nbgrader_grade_project(
1922
grade_project_id,
1923
);
1924
} else {
1925
if (student_project_id == null) {
1926
// This would happen if maybe instructor deletes student project at
1927
// the exact wrong time.
1928
// TODO: just create a new project for them?
1929
throw Error("student has no project, so can't run nbgrader");
1930
}
1931
grade_project_id = student_project_id;
1932
// grade right where student did their work.
1933
student_path = assignment.get("target_path");
1934
}
1935
1936
const where_grade =
1937
redux.getStore("projects").get_title(grade_project_id) ?? "a project";
1938
1939
const project_name = nbgrader_grade_project
1940
? `project ${trunc(where_grade, 40)}`
1941
: `${store.get_student_name(student_id)}'s project`;
1942
1943
if (instructor_ipynb_files == null) {
1944
instructor_ipynb_files =
1945
await this.nbgrader_instructor_ipynb_files(assignment_id);
1946
if (this.course_actions.is_closed()) return;
1947
}
1948
if (len(instructor_ipynb_files) == 0) {
1949
/* console.log(
1950
"run_nbgrader_for_one_student",
1951
assignment_id,
1952
student_id,
1953
"done -- no ipynb files"
1954
); */
1955
return; // nothing to do
1956
}
1957
1958
const result: { [path: string]: any } = {};
1959
const scores: { [filename: string]: NotebookScores | string } = {};
1960
1961
const one_file: (file: string) => Promise<void> = async (file) => {
1962
const activity_id = this.course_actions.set_activity({
1963
desc: `Running nbgrader on ${store.get_student_name(
1964
student_id,
1965
)}'s "${file}" in '${trunc(where_grade, 40)}'`,
1966
});
1967
if (assignment == null || student == null) {
1968
// This won't happen, but it makes Typescript happy.
1969
return;
1970
}
1971
try {
1972
// fullpath = where their collected work is.
1973
const fullpath = join(
1974
assignment.get("collect_path"),
1975
student.get("student_id"),
1976
file,
1977
);
1978
const student_ipynb: string = await jupyter_strip_notebook(
1979
course_project_id,
1980
fullpath,
1981
);
1982
if (instructor_ipynb_files == null) throw Error("BUG");
1983
const instructor_ipynb: string = instructor_ipynb_files[file];
1984
if (this.course_actions.is_closed()) return;
1985
1986
const id = this.course_actions.set_activity({
1987
desc: `Ensuring ${project_name} is running`,
1988
});
1989
1990
try {
1991
const did_start = await start_project(grade_project_id, 60);
1992
// if *we* started the student project, we'll also stop it afterwards
1993
if (!nbgrader_grade_project) {
1994
stop_student_project = did_start;
1995
}
1996
} finally {
1997
this.course_actions.clear_activity(id);
1998
}
1999
2000
let ephemeralGradePath;
2001
try {
2002
if (
2003
grade_project_id != course_project_id &&
2004
grade_project_id != student_project_id
2005
) {
2006
ephemeralGradePath = true;
2007
// Make a fresh copy of the assignment files to the grade project.
2008
// This is necessary because grading the assignment may depend on
2009
// data files that are sent as part of the assignment. Also,
2010
// student's might have some code in text files next to the ipynb.
2011
await webapp_client.project_client.copy_path_between_projects({
2012
src_project_id: course_project_id,
2013
src_path: student_path,
2014
target_project_id: grade_project_id,
2015
target_path: student_path,
2016
overwrite_newer: true,
2017
delete_missing: true,
2018
backup: false,
2019
timeout: COPY_TIMEOUT_MS,
2020
});
2021
} else {
2022
ephemeralGradePath = false;
2023
}
2024
2025
const opts = {
2026
timeout_ms: store.getIn(
2027
["settings", "nbgrader_timeout_ms"],
2028
NBGRADER_TIMEOUT_MS,
2029
),
2030
cell_timeout_ms: store.getIn(
2031
["settings", "nbgrader_cell_timeout_ms"],
2032
NBGRADER_CELL_TIMEOUT_MS,
2033
),
2034
max_output: store.getIn(
2035
["settings", "nbgrader_max_output"],
2036
NBGRADER_MAX_OUTPUT,
2037
),
2038
max_output_per_cell: store.getIn(
2039
["settings", "nbgrader_max_output_per_cell"],
2040
NBGRADER_MAX_OUTPUT_PER_CELL,
2041
),
2042
student_ipynb,
2043
instructor_ipynb,
2044
path: student_path,
2045
project_id: grade_project_id,
2046
};
2047
/*console.log(
2048
student_id,
2049
file,
2050
"about to launch autograding with input ",
2051
opts
2052
);*/
2053
const r = await nbgrader(opts);
2054
/* console.log(student_id, "autograding finished successfully", {
2055
file,
2056
r,
2057
});*/
2058
result[file] = r;
2059
} finally {
2060
if (ephemeralGradePath) {
2061
await webapp_client.project_client.exec({
2062
project_id: grade_project_id,
2063
command: "rm",
2064
args: ["-rf", student_path],
2065
});
2066
}
2067
}
2068
2069
if (!nbgrader_grade_project && stop_student_project) {
2070
const idstop = this.course_actions.set_activity({
2071
desc: `Stopping project ${project_name} after grading.`,
2072
});
2073
try {
2074
await stop_project(grade_project_id, 60);
2075
} finally {
2076
this.course_actions.clear_activity(idstop);
2077
}
2078
}
2079
} catch (err) {
2080
// console.log("nbgrader failed", { student_id, file, err });
2081
scores[file] = `${err}`;
2082
} finally {
2083
this.course_actions.clear_activity(activity_id);
2084
}
2085
};
2086
2087
// NOTE: we *could* run multiple files in parallel, but that causes
2088
// trouble for very little benefit. It's better to run across all students in parallel,
2089
// and the trouble is just that running lots of code in the same project can confuse
2090
// the backend api and use extra memory (which is unfair to students being graded, e.g.,
2091
// if their project has 1GB of RAM and we run 3 notebooks at once, they get "gypped").
2092
try {
2093
this.nbgrader_set_is_running(assignment_id, student_id);
2094
2095
for (const file in instructor_ipynb_files) {
2096
await one_file(file);
2097
}
2098
} finally {
2099
this.nbgrader_set_is_done(assignment_id, student_id);
2100
}
2101
/* console.log("ran nbgrader for all files for a student", {
2102
student_id,
2103
result
2104
}); */
2105
// Save any previous nbgrader scores for this student, so we can
2106
// preserve any manually entered scores, rather than overwrite them.
2107
const prev_scores = store.get_nbgrader_scores(assignment_id, student_id);
2108
2109
const nbgrader_score_ids: { [filename: string]: string[] } = {};
2110
2111
for (const filename in result) {
2112
const r = result[filename];
2113
if (r == null) continue;
2114
if (r.output == null) continue;
2115
if (r.ids != null) {
2116
nbgrader_score_ids[filename] = r.ids;
2117
}
2118
2119
// Depending on instructor options, write the graded version of
2120
// the notebook to disk, so the student can see why their grade
2121
// is what it is:
2122
const notebook = JSON.parse(r.output);
2123
scores[filename] = extract_auto_scores(notebook);
2124
if (
2125
prev_scores != null &&
2126
prev_scores[filename] != null &&
2127
typeof prev_scores[filename] != "string"
2128
) {
2129
// preserve any manual scores. cast since for some reason the typeof above isn't enough.
2130
for (const id in prev_scores[filename] as object) {
2131
const x = prev_scores[filename][id];
2132
if (x.manual && x.score && scores[filename][id] != null) {
2133
scores[filename][id].score = x.score;
2134
}
2135
}
2136
}
2137
2138
if (!nbgrader_include_hidden_tests) {
2139
// IMPORTANT: this *must* happen after extracting scores above!
2140
// Otherwise students get perfect grades.
2141
ipynb_clear_hidden_tests(notebook);
2142
}
2143
2144
await this.write_autograded_notebook(
2145
assignment,
2146
student_id,
2147
filename,
2148
JSON.stringify(notebook, undefined, 2),
2149
);
2150
}
2151
2152
this.set_nbgrader_scores_for_one_student(
2153
assignment_id,
2154
student_id,
2155
scores,
2156
nbgrader_score_ids,
2157
commit,
2158
);
2159
};
2160
2161
autograded_path = (
2162
assignment: AssignmentRecord,
2163
student_id: string,
2164
filename: string,
2165
): string => {
2166
return autograded_filename(
2167
join(assignment.get("collect_path"), student_id, filename),
2168
);
2169
};
2170
2171
private write_autograded_notebook = async (
2172
assignment: AssignmentRecord,
2173
student_id: string,
2174
filename: string,
2175
content: string,
2176
): Promise<void> => {
2177
const path = this.autograded_path(assignment, student_id, filename);
2178
await this.write_text_file_to_course_project({ path, content });
2179
};
2180
2181
open_file_in_collected_assignment = async (
2182
assignment_id: string,
2183
student_id: string,
2184
file: string,
2185
): Promise<void> => {
2186
const { assignment, student, store } = this.course_actions.resolve({
2187
assignment_id,
2188
student_id,
2189
});
2190
if (assignment == null || student == null) {
2191
throw Error("no such student or assignment");
2192
}
2193
const course_project_id = store.get("course_project_id");
2194
const fullpath = join(
2195
assignment.get("collect_path"),
2196
student.get("student_id"),
2197
file,
2198
);
2199
2200
await redux
2201
.getProjectActions(course_project_id)
2202
.open_file({ path: fullpath, foreground: true });
2203
};
2204
2205
private nbgrader_set_is_running = (
2206
assignment_id: string,
2207
student_id?: string,
2208
): void => {
2209
const store = this.get_store();
2210
let nbgrader_run_info: NBgraderRunInfo = store.get(
2211
"nbgrader_run_info",
2212
Map(),
2213
);
2214
const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;
2215
nbgrader_run_info = nbgrader_run_info.set(key, webapp_client.server_time());
2216
this.course_actions.setState({ nbgrader_run_info });
2217
};
2218
2219
private nbgrader_set_is_done = (
2220
assignment_id: string,
2221
student_id?: string,
2222
): void => {
2223
const store = this.get_store();
2224
let nbgrader_run_info: NBgraderRunInfo = store.get(
2225
"nbgrader_run_info",
2226
Map<string, number>(),
2227
);
2228
const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;
2229
nbgrader_run_info = nbgrader_run_info.delete(key);
2230
this.course_actions.setState({ nbgrader_run_info });
2231
};
2232
2233
export_file_use_times = async (
2234
assignment_id: string,
2235
json_filename: string,
2236
): Promise<void> => {
2237
// Get the path of the assignment
2238
const { assignment, store } = this.course_actions.resolve({
2239
assignment_id,
2240
});
2241
if (assignment == null) {
2242
throw Error("no such assignment");
2243
}
2244
const src_path = this.assignment_src_path(assignment);
2245
const target_path = assignment.get("path");
2246
await export_student_file_use_times(
2247
store.get("course_project_id"),
2248
src_path,
2249
target_path,
2250
store.get("students"),
2251
json_filename,
2252
store.get_student_name.bind(store),
2253
);
2254
};
2255
2256
export_collected = async (assignment_id: string): Promise<void> => {
2257
const set_activity = this.course_actions.set_activity.bind(
2258
this.course_actions,
2259
);
2260
const id = set_activity({
2261
desc: "Exporting collected files...",
2262
});
2263
try {
2264
const { assignment, store } = this.course_actions.resolve({
2265
assignment_id,
2266
});
2267
if (assignment == null) return;
2268
const students = store.get("students");
2269
const src_path = this.assignment_src_path(assignment);
2270
const collect_path = assignment.get("collect_path");
2271
const i = store.get("course_filename").lastIndexOf(".");
2272
const base_export_path =
2273
store.get("course_filename").slice(0, i) + "-export";
2274
const export_path = join(base_export_path, src_path);
2275
2276
const student_name = function (student_id: string): string {
2277
const v = split(store.get_student_name(student_id));
2278
return v.join("_");
2279
};
2280
2281
const activity = function (s: string): void {
2282
set_activity({
2283
id,
2284
desc: "Exporting collected files... " + s,
2285
});
2286
};
2287
2288
const project_id = store.get("course_project_id");
2289
2290
await export_assignment(
2291
project_id,
2292
collect_path,
2293
export_path,
2294
students,
2295
student_name,
2296
activity,
2297
);
2298
2299
redux.getProjectActions(project_id).open_directory(base_export_path);
2300
} catch (err) {
2301
this.course_actions.set_error(
2302
`Error exporting collected student files -- ${err}`,
2303
);
2304
} finally {
2305
set_activity({ id });
2306
}
2307
};
2308
}
2309
2310