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