CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/course/assignments/actions.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Actions involving 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
16
import { redux } from "@cocalc/frontend/app-framework";
17
import {
18
exec,
19
start_project,
20
stop_project,
21
} from "@cocalc/frontend/frame-editors/generic/client";
22
import {
23
jupyter_strip_notebook,
24
nbgrader,
25
} from "@cocalc/frontend/jupyter/nbgrader/api";
26
import {
27
extract_auto_scores,
28
NotebookScores,
29
} from "@cocalc/frontend/jupyter/nbgrader/autograde";
30
import { ipynb_clear_hidden_tests } from "@cocalc/frontend/jupyter/nbgrader/clear-hidden-tests";
31
import { webapp_client } from "@cocalc/frontend/webapp-client";
32
import {
33
defaults,
34
endswith,
35
len,
36
path_split,
37
peer_grading,
38
split,
39
trunc,
40
uuid,
41
} from "@cocalc/util/misc";
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 / 1000,
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 / 1000,
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
await webapp_client.project_client.copy_path_between_projects({
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 / 1000,
865
});
866
867
// successful finish
868
finish();
869
} catch (err) {
870
// error somewhere along the way
871
finish(err);
872
}
873
};
874
875
private assignment_src_path = (assignment): string => {
876
let path = assignment.get("path");
877
if (assignment.get("has_student_subdir")) {
878
path = join(path, STUDENT_SUBDIR);
879
}
880
return path;
881
};
882
883
// this is part of the assignment disribution, should be done only *once*, not for every student
884
private copy_assignment_create_due_date_file = async (
885
assignment_id: string,
886
): Promise<void> => {
887
const { assignment } = this.course_actions.resolve({
888
assignment_id,
889
});
890
if (!assignment) return;
891
// write the due date to a file
892
const src_path = this.assignment_src_path(assignment);
893
const due_id = this.course_actions.set_activity({
894
desc: `Creating ${DUE_DATE_FILENAME} file...`,
895
});
896
const content = this.dueDateFileContent(assignment_id);
897
const path = join(src_path, DUE_DATE_FILENAME);
898
899
try {
900
await this.write_text_file_to_course_project({
901
path,
902
content,
903
});
904
} catch (err) {
905
throw Error(
906
`Problem writing ${DUE_DATE_FILENAME} file ('${err}'). Try again...`,
907
);
908
} finally {
909
this.course_actions.clear_activity(due_id);
910
}
911
};
912
913
private dueDateFileContent = (assignment_id) => {
914
const due_date = this.get_store()?.get_due_date(assignment_id);
915
if (due_date) {
916
return `This assignment is due\n\n ${due_date.toLocaleString()}`;
917
} else {
918
return "No due date has been set.";
919
}
920
};
921
922
copy_assignment = async (
923
type: AssignmentCopyType,
924
assignment_id: string,
925
student_id: string,
926
): Promise<void> => {
927
// type = assigned, collected, graded, peer-assigned, peer-collected
928
switch (type) {
929
case "assigned":
930
// make sure listing is up to date, since it sets "has_student_subdir",
931
// which impacts the distribute semantics.
932
await this.update_listing(assignment_id);
933
await this.copy_assignment_to_student(assignment_id, student_id, {
934
create_due_date_file: true,
935
});
936
return;
937
case "collected":
938
await this.copy_assignment_from_student(assignment_id, student_id);
939
return;
940
case "graded":
941
await this.return_assignment_to_student(assignment_id, student_id);
942
return;
943
case "peer-assigned":
944
await this.peer_copy_to_student(assignment_id, student_id);
945
return;
946
case "peer-collected":
947
await this.peer_collect_from_student(assignment_id, student_id);
948
return;
949
default:
950
this.course_actions.set_error(
951
`copy_assignment -- unknown type: ${type}`,
952
);
953
return;
954
}
955
};
956
957
// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.
958
copy_assignment_to_all_students = async (
959
assignment_id: string,
960
new_only: boolean,
961
overwrite: boolean,
962
): Promise<void> => {
963
const desc = `Copying assignments to all students ${
964
new_only ? "who have not already received it" : ""
965
}`;
966
const short_desc = "copy to student";
967
await this.update_listing(assignment_id); // make sure this is up to date
968
if (this.course_actions.is_closed()) return;
969
await this.copy_assignment_create_due_date_file(assignment_id);
970
if (this.course_actions.is_closed()) return;
971
// by default, doesn't create the due file
972
await this.assignment_action_all_students({
973
assignment_id,
974
new_only,
975
action: this.copy_assignment_to_student,
976
step: "assignment",
977
desc,
978
short_desc,
979
overwrite,
980
});
981
};
982
983
// Copy the given assignment to from all non-deleted students, doing several copies in parallel at once.
984
copy_assignment_from_all_students = async (
985
assignment_id: string,
986
new_only: boolean,
987
): Promise<void> => {
988
let desc = "Copying assignment from all students";
989
if (new_only) {
990
desc += " from whom we have not already copied it";
991
}
992
const short_desc = "copy from student";
993
await this.assignment_action_all_students({
994
assignment_id,
995
new_only,
996
action: this.copy_assignment_from_student,
997
step: "collect",
998
desc,
999
short_desc,
1000
});
1001
};
1002
1003
private start_all_for_peer_grading = async (): Promise<void> => {
1004
// On cocalc.com, if the student projects get started specifically
1005
// for the purposes of copying files to/from them, then they stop
1006
// around a minute later. This is very bad for peer grading, since
1007
// so much copying occurs, and we end up with conflicts between
1008
// projects starting to peer grade, then stop, then needing to be
1009
// started again all at once. We thus request that they all start,
1010
// wait a few seconds for that "reason" for them to be running to
1011
// take effect, and then do the copy. This way the projects aren't
1012
// automatically stopped after the copies happen.
1013
const id = this.course_actions.set_activity({
1014
desc: "Warming up all student projects for peer grading...",
1015
});
1016
this.course_actions.student_projects.action_all_student_projects("start");
1017
// We request to start all projects simultaneously, and the system
1018
// will start doing that. I think it's not so much important that
1019
// the projects are actually running, but that they were started
1020
// before the copy operations started.
1021
await delay(5 * 1000);
1022
this.course_actions.clear_activity(id);
1023
};
1024
1025
async peer_copy_to_all_students(
1026
assignment_id: string,
1027
new_only: boolean,
1028
): Promise<void> {
1029
let desc = "Copying assignments for peer grading to all students ";
1030
if (new_only) {
1031
desc += " who have not already received their copy";
1032
}
1033
const short_desc = "copy to student for peer grading";
1034
// CRITICAL: be sure to run this update once before doing the
1035
// assignment. Otherwise, since assignment runs more than once
1036
// in parallel, two will launch at about the same time and
1037
// the *condition* to know if it is done depends on the store,
1038
// which defers when it gets updated. Anyway, this line is critical:
1039
try {
1040
this.update_peer_assignment(assignment_id);
1041
} catch (err) {
1042
this.course_actions.set_error(`${short_desc} -- ${err}`);
1043
return;
1044
}
1045
await this.start_all_for_peer_grading();
1046
// OK, now do the assignment... in parallel.
1047
await this.assignment_action_all_students({
1048
assignment_id,
1049
new_only,
1050
action: this.peer_copy_to_student,
1051
step: "peer_assignment",
1052
desc,
1053
short_desc,
1054
});
1055
}
1056
1057
async peer_collect_from_all_students(
1058
assignment_id: string,
1059
new_only: boolean,
1060
): Promise<void> {
1061
let desc = "Copying peer graded assignments from all students";
1062
if (new_only) {
1063
desc += " from whom we have not already copied it";
1064
}
1065
const short_desc = "copy peer grading from students";
1066
await this.start_all_for_peer_grading();
1067
await this.assignment_action_all_students({
1068
assignment_id,
1069
new_only,
1070
action: this.peer_collect_from_student,
1071
step: "peer_collect",
1072
desc,
1073
short_desc,
1074
});
1075
await this.peerParseStudentGrading(assignment_id);
1076
}
1077
1078
private peerParseStudentGrading = async (assignment_id: string) => {
1079
// For each student do the following:
1080
// If they already have a recorded grade, do nothing further.
1081
// If they do not have a recorded grade, load all of the
1082
// PEER_GRADING_GUIDE_FILENAME files that were collected
1083
// from the students, then create a grade from that (if possible), along
1084
// with a comment that explains how that grade was obtained, without
1085
// saying which student did what.
1086
const { store, assignment } = this.course_actions.resolve({
1087
assignment_id,
1088
});
1089
if (assignment == null) {
1090
throw Error("no such assignment");
1091
}
1092
const id = this.course_actions.set_activity({
1093
desc: "Parsing peer grading",
1094
});
1095
const allGrades = assignment.get("grades", Map()).toJS() as {
1096
[student_id: string]: string;
1097
};
1098
const allComments = assignment.get("comments", Map()).toJS() as {
1099
[student_id: string]: string;
1100
};
1101
// compute missing grades
1102
for (const student_id of store.get_student_ids()) {
1103
if (allGrades[student_id]) {
1104
// a grade is already set
1105
continue;
1106
}
1107
// attempt to compute a grade
1108
const peer_student_ids: string[] = store.get_peers_that_graded_student(
1109
assignment_id,
1110
student_id,
1111
);
1112
const course_project_id = store.get("course_project_id");
1113
const grades: number[] = [];
1114
let comments: string[] = [];
1115
const student_name = store.get_student_name(student_id);
1116
this.course_actions.set_activity({
1117
id,
1118
desc: `Parsing peer grading of ${student_name}`,
1119
});
1120
for (const peer_student_id of peer_student_ids) {
1121
const path = join(
1122
`${assignment.get("collect_path")}-peer-grade`,
1123
student_id,
1124
peer_student_id,
1125
PEER_GRADING_GUIDE_FILENAME,
1126
);
1127
try {
1128
const contents = await webapp_client.project_client.read_text_file({
1129
project_id: course_project_id,
1130
path,
1131
});
1132
const i = contents.lastIndexOf(PEER_GRADING_GUIDELINES_GRADE_MARKER);
1133
if (i == -1) {
1134
continue;
1135
}
1136
let j = contents.lastIndexOf(PEER_GRADING_GUIDELINES_COMMENT_MARKER);
1137
if (j == -1) {
1138
j = contents.length;
1139
}
1140
const grade = parseFloat(
1141
contents
1142
.slice(i + PEER_GRADING_GUIDELINES_GRADE_MARKER.length, j)
1143
.trim(),
1144
);
1145
if (!isFinite(grade) && isNaN(grade)) {
1146
continue;
1147
}
1148
const comment = contents.slice(
1149
j + PEER_GRADING_GUIDELINES_COMMENT_MARKER.length,
1150
);
1151
grades.push(grade);
1152
comments.push(comment);
1153
} catch (err) {
1154
// grade not available for some reason
1155
console.warn("issue reading peer grading file", {
1156
path,
1157
err,
1158
student_name,
1159
});
1160
}
1161
}
1162
if (grades.length > 0) {
1163
const grade = grades.reduce((a, b) => a + b) / grades.length;
1164
allGrades[student_id] = `${grade}`;
1165
if (!allComments[student_id]) {
1166
const studentComments = comments
1167
.filter((x) => x.trim())
1168
.map((x) => `- ${x.trim()}`)
1169
.join("\n\n");
1170
allComments[student_id] = `Grades: ${grades.join(", ")}\n\n${
1171
studentComments ? "Student Comments:\n" + studentComments : ""
1172
}`;
1173
}
1174
}
1175
}
1176
// set them in the course data
1177
this.course_actions.set(
1178
{
1179
table: "assignments",
1180
assignment_id,
1181
grades: allGrades,
1182
comments: allComments,
1183
},
1184
true,
1185
);
1186
this.course_actions.clear_activity(id);
1187
};
1188
1189
private assignment_action_all_students = async ({
1190
assignment_id,
1191
new_only,
1192
old_only,
1193
action,
1194
step,
1195
desc,
1196
short_desc,
1197
overwrite,
1198
}: {
1199
assignment_id: string;
1200
// only do the action when it hasn't been done already
1201
new_only?: boolean;
1202
// only do the action when it HAS been done already
1203
old_only?: boolean;
1204
action: (
1205
assignment_id: string,
1206
student_id: string,
1207
opts: any,
1208
) => Promise<void>;
1209
step;
1210
desc;
1211
short_desc: string;
1212
overwrite?: boolean;
1213
}): Promise<void> => {
1214
if (new_only && old_only) {
1215
// no matter what, this means the empty set, so nothing to do.
1216
// Of course no code shouild actually call this.
1217
return;
1218
}
1219
const id = this.course_actions.set_activity({ desc });
1220
const finish = (err) => {
1221
this.course_actions.clear_activity(id);
1222
err = `${short_desc}: ${err}`;
1223
this.course_actions.set_error(err);
1224
};
1225
const { store, assignment } = this.course_actions.resolve({
1226
assignment_id,
1227
finish,
1228
});
1229
if (!assignment) return;
1230
let errors = "";
1231
const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);
1232
const prev_step =
1233
step == "assignment" ? undefined : previous_step(step, peer);
1234
const f = async (student_id: string): Promise<void> => {
1235
if (this.course_actions.is_closed()) return;
1236
const store = this.get_store();
1237
if (
1238
prev_step != null &&
1239
!store.last_copied(prev_step, assignment_id, student_id, true)
1240
) {
1241
return;
1242
}
1243
const alreadyCopied = !!store.last_copied(
1244
step,
1245
assignment_id,
1246
student_id,
1247
true,
1248
);
1249
if (new_only && alreadyCopied) {
1250
// only for the ones that haven't already been copied
1251
return;
1252
}
1253
if (old_only && !alreadyCopied) {
1254
// only for the ones that *HAVE* already been copied.
1255
return;
1256
}
1257
try {
1258
await action(assignment_id, student_id, { overwrite });
1259
} catch (err) {
1260
errors += `\n ${err}`;
1261
}
1262
};
1263
1264
await map(
1265
store.get_student_ids({ deleted: false }),
1266
store.get_copy_parallel(),
1267
f,
1268
);
1269
1270
if (errors) {
1271
finish(errors);
1272
} else {
1273
this.course_actions.clear_activity(id);
1274
}
1275
};
1276
1277
// Copy the collected folders from some students to the given student for peer grading.
1278
// Assumes folder is non-empty
1279
private peer_copy_to_student = async (
1280
assignment_id: string,
1281
student_id: string,
1282
): Promise<void> => {
1283
if (this.start_copy(assignment_id, student_id, "last_peer_assignment")) {
1284
return;
1285
}
1286
const id = this.course_actions.set_activity({
1287
desc: "Copying peer grading to a student",
1288
});
1289
const finish = (err?) => {
1290
this.course_actions.clear_activity(id);
1291
this.finish_copy(assignment_id, student_id, "last_peer_assignment", err);
1292
if (err) {
1293
this.course_actions.set_error(`copy peer-grading to student: ${err}`);
1294
}
1295
};
1296
const { store, student, assignment } = this.course_actions.resolve({
1297
assignment_id,
1298
student_id,
1299
finish,
1300
});
1301
if (!student || !assignment) return;
1302
1303
const student_name = store.get_student_name(student_id);
1304
this.course_actions.set_activity({
1305
id,
1306
desc: `Copying peer grading to ${student_name}`,
1307
});
1308
1309
let peer_map;
1310
try {
1311
// synchronous, but could fail, e.g., not enough students
1312
peer_map = this.update_peer_assignment(assignment_id);
1313
} catch (err) {
1314
this.course_actions.set_error(`peer copy to student: ${err}`);
1315
finish();
1316
return;
1317
}
1318
1319
if (peer_map == null) {
1320
finish();
1321
return;
1322
}
1323
1324
const peers = peer_map[student.get("student_id")];
1325
if (peers == null) {
1326
// empty peer assignment for this student (maybe student added after
1327
// peer assignment already created?)
1328
finish();
1329
return;
1330
}
1331
1332
const student_project_id = student.get("project_id");
1333
if (!student_project_id) {
1334
finish();
1335
return;
1336
}
1337
1338
let guidelines: string = assignment.getIn(
1339
["peer_grade", "guidelines"],
1340
"Please grade this assignment.",
1341
);
1342
const due_date = assignment.getIn(["peer_grade", "due_date"]);
1343
if (due_date != null) {
1344
guidelines =
1345
`GRADING IS DUE ${new Date(due_date).toLocaleString()} \n\n ` +
1346
guidelines;
1347
}
1348
1349
const target_base_path = assignment.get("path") + "-peer-grade";
1350
const f = async (peer_student_id: string) => {
1351
if (this.course_actions.is_closed()) {
1352
return;
1353
}
1354
const src_path = join(assignment.get("collect_path"), peer_student_id);
1355
// write instructions file for the student, where they enter the grade,
1356
// and also it tells them what to do.
1357
await this.write_text_file_to_course_project({
1358
path: join(src_path, PEER_GRADING_GUIDE_FILENAME),
1359
content: guidelines,
1360
});
1361
const target_path = join(target_base_path, peer_student_id);
1362
// In the copy below, we exclude the student's name so that
1363
// peer grading is anonymous; also, remove original
1364
// due date to avoid confusion.
1365
// copy the files to be peer graded into place for this student
1366
await webapp_client.project_client.copy_path_between_projects({
1367
src_project_id: store.get("course_project_id"),
1368
src_path,
1369
target_project_id: student_project_id,
1370
target_path,
1371
overwrite_newer: false,
1372
delete_missing: false,
1373
exclude: ["*STUDENT*.txt", "*" + DUE_DATE_FILENAME + "*"],
1374
timeout: COPY_TIMEOUT_MS / 1000,
1375
});
1376
};
1377
1378
try {
1379
// now copy actual stuff to grade
1380
await map(peers, store.get_copy_parallel(), f);
1381
finish();
1382
} catch (err) {
1383
finish(err);
1384
return;
1385
}
1386
};
1387
1388
// Collect all the peer graading of the given student (not the work the student did, but
1389
// the grading about the student!).
1390
private peer_collect_from_student = async (
1391
assignment_id: string,
1392
student_id: string,
1393
): Promise<void> => {
1394
if (this.start_copy(assignment_id, student_id, "last_peer_collect")) {
1395
return;
1396
}
1397
const id = this.course_actions.set_activity({
1398
desc: "Collecting peer grading of a student",
1399
});
1400
const finish = (err?) => {
1401
this.course_actions.clear_activity(id);
1402
this.finish_copy(assignment_id, student_id, "last_peer_collect", err);
1403
if (err) {
1404
this.course_actions.set_error(
1405
`collecting peer-grading of a student: ${err}`,
1406
);
1407
}
1408
};
1409
1410
const { store, student, assignment } = this.course_actions.resolve({
1411
student_id,
1412
assignment_id,
1413
finish,
1414
});
1415
if (!student || !assignment) return;
1416
1417
const student_name = store.get_student_name(student_id);
1418
this.course_actions.set_activity({
1419
id,
1420
desc: `Collecting peer grading of ${student_name}`,
1421
});
1422
1423
// list of student_id of students that graded this student (may be empty)
1424
const peers: string[] = store.get_peers_that_graded_student(
1425
assignment_id,
1426
student_id,
1427
);
1428
1429
const our_student_id = student.get("student_id");
1430
1431
const f = async (student_id: string): Promise<void> => {
1432
const s = store.get_student(student_id);
1433
// ignore deleted or non-existent students
1434
if (s == null || s.get("deleted")) return;
1435
1436
const path = assignment.get("path");
1437
const src_path = join(`${path}-peer-grade`, our_student_id);
1438
const target_path = join(
1439
`${assignment.get("collect_path")}-peer-grade`,
1440
our_student_id,
1441
student_id,
1442
);
1443
1444
const src_project_id = s.get("project_id");
1445
if (!src_project_id) {
1446
return;
1447
}
1448
1449
// copy the files over from the student who did the peer grading
1450
await webapp_client.project_client.copy_path_between_projects({
1451
src_project_id,
1452
src_path,
1453
target_project_id: store.get("course_project_id"),
1454
target_path,
1455
overwrite_newer: false,
1456
delete_missing: false,
1457
timeout: COPY_TIMEOUT_MS / 1000,
1458
});
1459
1460
// write local file identifying the grader
1461
let name = store.get_student_name_extra(student_id);
1462
await this.write_text_file_to_course_project({
1463
path: target_path + `/GRADER - ${name.simple}.txt`,
1464
content: `The student who did the peer grading is named ${name.full}.`,
1465
});
1466
1467
// write local file identifying student being graded
1468
name = store.get_student_name_extra(our_student_id);
1469
await this.write_text_file_to_course_project({
1470
path: target_path + `/STUDENT - ${name.simple}.txt`,
1471
content: `This student is ${name.full}.`,
1472
});
1473
};
1474
1475
try {
1476
await map(peers, store.get_copy_parallel(), f);
1477
finish();
1478
} catch (err) {
1479
finish(err);
1480
}
1481
};
1482
1483
// This doesn't really stop it yet, since that's not supported by the backend.
1484
// It does stop the spinner and let the user try to restart the copy.
1485
stop_copying_assignment = (
1486
assignment_id: string,
1487
student_id: string,
1488
type: AssignmentCopyType,
1489
): void => {
1490
this.stop_copy(assignment_id, student_id, copy_type_to_last(type));
1491
};
1492
1493
open_assignment = (
1494
type: AssignmentCopyType,
1495
assignment_id: string,
1496
student_id: string,
1497
): void => {
1498
const { store, assignment, student } = this.course_actions.resolve({
1499
assignment_id,
1500
student_id,
1501
});
1502
if (assignment == null || student == null) return;
1503
const student_project_id = student.get("project_id");
1504
if (student_project_id == null) {
1505
this.course_actions.set_error(
1506
"open_assignment: student project not yet created",
1507
);
1508
return;
1509
}
1510
// Figure out what to open
1511
let path, proj;
1512
switch (type) {
1513
case "assigned": // where project was copied in the student's project.
1514
path = assignment.get("target_path");
1515
proj = student_project_id;
1516
break;
1517
case "collected": // where collected locally
1518
path = join(assignment.get("collect_path"), student.get("student_id")); // TODO: refactor
1519
proj = store.get("course_project_id");
1520
break;
1521
case "peer-assigned": // where peer-assigned (in student's project)
1522
proj = student_project_id;
1523
path = assignment.get("path") + "-peer-grade";
1524
break;
1525
case "peer-collected": // where collected peer-graded work (in our project)
1526
path =
1527
assignment.get("collect_path") +
1528
"-peer-grade/" +
1529
student.get("student_id");
1530
proj = store.get("course_project_id");
1531
break;
1532
case "graded": // where project returned
1533
path = assignment.get("graded_path"); // refactor
1534
proj = student_project_id;
1535
break;
1536
default:
1537
this.course_actions.set_error(
1538
`open_assignment -- unknown type: ${type}`,
1539
);
1540
}
1541
if (proj == null) {
1542
this.course_actions.set_error("no such project");
1543
return;
1544
}
1545
// Now open it
1546
redux.getProjectActions(proj).open_directory(path);
1547
};
1548
1549
private write_text_file_to_course_project = async (opts: {
1550
path: string;
1551
content: string;
1552
}): Promise<void> => {
1553
await webapp_client.project_client.write_text_file({
1554
project_id: this.get_store().get("course_project_id"),
1555
path: opts.path,
1556
content: opts.content,
1557
});
1558
};
1559
1560
// Update datastore with directory listing of non-hidden content of the assignment.
1561
// This also sets whether or not there is a STUDENT_SUBDIR directory.
1562
update_listing = async (assignment_id: string): Promise<void> => {
1563
const { store, assignment } = this.course_actions.resolve({
1564
assignment_id,
1565
});
1566
if (assignment == null) return;
1567
const project_id = store.get("course_project_id");
1568
const path = assignment.get("path");
1569
if (project_id == null || path == null) return;
1570
let listing;
1571
try {
1572
const { files } = await webapp_client.project_client.directory_listing({
1573
project_id,
1574
path,
1575
hidden: false,
1576
compute_server_id: 0, // TODO
1577
});
1578
listing = files;
1579
} catch (err) {
1580
// This might happen, e.g., if the assignment directory is deleted or user messes
1581
// with permissions...
1582
// In this case, just give up.
1583
return;
1584
}
1585
if (listing == null || this.course_actions.is_closed()) return;
1586
this.course_actions.set({
1587
listing,
1588
assignment_id,
1589
table: "assignments",
1590
});
1591
1592
let has_student_subdir: boolean = false;
1593
for (const entry of listing) {
1594
if (entry.isdir && entry.name == STUDENT_SUBDIR) {
1595
has_student_subdir = true;
1596
break;
1597
}
1598
}
1599
const nbgrader = await this.has_nbgrader_metadata(assignment_id);
1600
if (this.course_actions.is_closed()) return;
1601
this.course_actions.set({
1602
has_student_subdir,
1603
nbgrader,
1604
assignment_id,
1605
table: "assignments",
1606
});
1607
};
1608
1609
/* Scan all Jupyter notebooks in the top level of either the assignment directory or
1610
the student/
1611
subdirectory of it for cells with nbgrader metadata. If any are found, return
1612
true; otherwise, return false.
1613
*/
1614
private has_nbgrader_metadata = async (
1615
assignment_id: string,
1616
): Promise<boolean> => {
1617
return len(await this.nbgrader_instructor_ipynb_files(assignment_id)) > 0;
1618
};
1619
1620
// Read in the (stripped) contents of all nbgrader instructor ipynb
1621
// files for this assignment. These are:
1622
// - Every ipynb file in the assignment directory that has a cell that
1623
// contains nbgrader metadata (and isn't mangled).
1624
private nbgrader_instructor_ipynb_files = async (
1625
assignment_id: string,
1626
): Promise<{ [path: string]: string }> => {
1627
const { store, assignment } = this.course_actions.resolve({
1628
assignment_id,
1629
});
1630
if (assignment == null) {
1631
return {}; // nothing case.
1632
}
1633
const path = assignment.get("path");
1634
const project_id = store.get("course_project_id");
1635
let files;
1636
try {
1637
files = await redux
1638
.getProjectStore(project_id)
1639
.get_listings()
1640
.getListingDirectly(path);
1641
} catch (err) {
1642
// This happens, e.g., if the instructor moves the directory
1643
// that contains their version of the ipynb file.
1644
// See https://github.com/sagemathinc/cocalc/issues/5501
1645
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})`;
1646
this.course_actions.set_error(error);
1647
throw err;
1648
}
1649
const result: { [path: string]: string } = {};
1650
1651
if (this.course_actions.is_closed()) return result;
1652
1653
const to_read = files
1654
.filter((entry) => !entry.isdir && endswith(entry.name, ".ipynb"))
1655
.map((entry) => entry.name);
1656
1657
const f: (file: string) => Promise<void> = async (file) => {
1658
if (this.course_actions.is_closed()) return;
1659
const fullpath = path != "" ? join(path, file) : file;
1660
try {
1661
const content = await jupyter_strip_notebook(project_id, fullpath);
1662
const { cells } = JSON.parse(content);
1663
for (const cell of cells) {
1664
if (cell.metadata.nbgrader) {
1665
result[file] = content;
1666
return;
1667
}
1668
}
1669
} catch (err) {
1670
return;
1671
}
1672
};
1673
1674
await map(to_read, 10, f);
1675
return result;
1676
};
1677
1678
// Run nbgrader for all students for which this assignment
1679
// has been collected at least once.
1680
run_nbgrader_for_all_students = async (
1681
assignment_id: string,
1682
ungraded_only?: boolean,
1683
): Promise<void> => {
1684
// console.log("run_nbgrader_for_all_students", assignment_id);
1685
const instructor_ipynb_files = await this.nbgrader_instructor_ipynb_files(
1686
assignment_id,
1687
);
1688
if (this.course_actions.is_closed()) return;
1689
const store = this.get_store();
1690
const nbgrader_scores = store.getIn([
1691
"assignments",
1692
assignment_id,
1693
"nbgrader_scores",
1694
]);
1695
const one_student: (student_id: string) => Promise<void> = async (
1696
student_id,
1697
) => {
1698
if (this.course_actions.is_closed()) return;
1699
if (!store.last_copied("collect", assignment_id, student_id, true)) {
1700
// Do not try to grade the assignment, since it wasn't
1701
// already successfully collected yet.
1702
return;
1703
}
1704
if (
1705
ungraded_only &&
1706
grading_state(student_id, nbgrader_scores) == "succeeded"
1707
) {
1708
// Do not try to grade assignment, if it has already been successfully graded.
1709
return;
1710
}
1711
await this.run_nbgrader_for_one_student(
1712
assignment_id,
1713
student_id,
1714
instructor_ipynb_files,
1715
true,
1716
);
1717
};
1718
try {
1719
this.nbgrader_set_is_running(assignment_id);
1720
await map(
1721
this.get_store().get_student_ids({ deleted: false }),
1722
this.get_store().get_nbgrader_parallel(),
1723
one_student,
1724
);
1725
this.course_actions.syncdb.commit();
1726
} finally {
1727
this.nbgrader_set_is_done(assignment_id);
1728
}
1729
};
1730
1731
set_nbgrader_scores_for_all_students = ({
1732
assignment_id,
1733
force,
1734
commit,
1735
}: {
1736
assignment_id: string;
1737
force?: boolean;
1738
commit?: boolean;
1739
}): void => {
1740
for (const student_id of this.get_store().get_student_ids({
1741
deleted: false,
1742
})) {
1743
this.set_grade_using_nbgrader_if_possible(
1744
assignment_id,
1745
student_id,
1746
false,
1747
force,
1748
);
1749
}
1750
if (commit) {
1751
this.course_actions.syncdb.commit();
1752
}
1753
};
1754
1755
set_nbgrader_scores_for_one_student = (
1756
assignment_id: string,
1757
student_id: string,
1758
scores: { [filename: string]: NotebookScores | string },
1759
nbgrader_score_ids:
1760
| { [filename: string]: string[] }
1761
| undefined = undefined,
1762
commit: boolean = true,
1763
): void => {
1764
const assignment_data = this.course_actions.get_one({
1765
table: "assignments",
1766
assignment_id,
1767
});
1768
if (assignment_data == null) return;
1769
const nbgrader_scores: {
1770
[student_id: string]: { [ipynb: string]: NotebookScores | string };
1771
} = assignment_data.nbgrader_scores || {};
1772
nbgrader_scores[student_id] = scores;
1773
this.course_actions.set(
1774
{
1775
table: "assignments",
1776
assignment_id,
1777
nbgrader_scores,
1778
...(nbgrader_score_ids != null ? { nbgrader_score_ids } : undefined),
1779
},
1780
commit,
1781
);
1782
this.set_grade_using_nbgrader_if_possible(
1783
assignment_id,
1784
student_id,
1785
commit,
1786
);
1787
};
1788
1789
set_specific_nbgrader_score = (
1790
assignment_id: string,
1791
student_id: string,
1792
filename: string,
1793
grade_id: string,
1794
score: number,
1795
commit: boolean = true,
1796
): void => {
1797
const { assignment } = this.course_actions.resolve({
1798
assignment_id,
1799
});
1800
if (assignment == null) {
1801
throw Error("no such assignment");
1802
}
1803
1804
const scores: any = assignment
1805
.getIn(["nbgrader_scores", student_id], Map())
1806
.toJS();
1807
let x: any = scores[filename];
1808
if (x == null) {
1809
x = scores[filename] = {};
1810
}
1811
let y = x[grade_id];
1812
if (y == null) {
1813
y = x[grade_id] = {};
1814
}
1815
y.score = score;
1816
if (y.points != null && y.score > y.points) {
1817
y.score = y.points;
1818
}
1819
if (y.score < 0) {
1820
y.score = 0;
1821
}
1822
this.set_nbgrader_scores_for_one_student(
1823
assignment_id,
1824
student_id,
1825
scores,
1826
undefined,
1827
commit,
1828
);
1829
1830
this.set_grade_using_nbgrader_if_possible(
1831
assignment_id,
1832
student_id,
1833
commit,
1834
);
1835
};
1836
1837
// Fill in manual grade if it is blank and there is an nbgrader grade
1838
// and all the manual nbgrader scores have been filled in.
1839
// Also, the filled in grade uses a specific format [number]/[total]
1840
// and if this is maintained and the nbgrader scores change, this
1841
// the manual grade is updated.
1842
set_grade_using_nbgrader_if_possible = (
1843
assignment_id: string,
1844
student_id: string,
1845
commit: boolean = true,
1846
force: boolean = false,
1847
): void => {
1848
// Check if nbgrader scores are all available.
1849
const store = this.get_store();
1850
const scores = store.get_nbgrader_scores(assignment_id, student_id);
1851
if (scores == null) {
1852
// no info -- maybe nbgrader not even run yet.
1853
return;
1854
}
1855
const { score, points, error, manual_needed } = get_nbgrader_score(scores);
1856
if (!force && (error || manual_needed)) {
1857
// more work must be done before we can use this.
1858
return;
1859
}
1860
1861
// Fill in the overall grade if either it is currently unset, blank,
1862
// or of the form [number]/[number].
1863
const grade = store.get_grade(assignment_id, student_id).trim();
1864
if (force || grade == "" || grade.match(/\d+\/\d+/g)) {
1865
this.set_grade(assignment_id, student_id, `${score}/${points}`, commit);
1866
}
1867
};
1868
1869
run_nbgrader_for_one_student = async (
1870
assignment_id: string,
1871
student_id: string,
1872
instructor_ipynb_files?: { [path: string]: string },
1873
commit: boolean = true,
1874
): Promise<void> => {
1875
// console.log("run_nbgrader_for_one_student", assignment_id, student_id);
1876
1877
const { store, assignment, student } = this.course_actions.resolve({
1878
assignment_id,
1879
student_id,
1880
});
1881
1882
if (
1883
student == null ||
1884
assignment == null ||
1885
!assignment.get("has_student_subdir")
1886
) {
1887
return; // nothing case.
1888
}
1889
1890
const nbgrader_grade_project: string | undefined = store.getIn([
1891
"settings",
1892
"nbgrader_grade_project",
1893
]);
1894
1895
const nbgrader_include_hidden_tests: boolean = !!store.getIn([
1896
"settings",
1897
"nbgrader_include_hidden_tests",
1898
]);
1899
1900
const course_project_id = store.get("course_project_id");
1901
const student_project_id = student.get("project_id");
1902
1903
let grade_project_id: string;
1904
let student_path: string;
1905
let stop_student_project = false;
1906
if (nbgrader_grade_project) {
1907
grade_project_id = nbgrader_grade_project;
1908
1909
// grade in the path where we collected their work.
1910
student_path = join(
1911
assignment.get("collect_path"),
1912
student.get("student_id"),
1913
);
1914
1915
this.course_actions.configuration.configure_nbgrader_grade_project(
1916
grade_project_id,
1917
);
1918
} else {
1919
if (student_project_id == null) {
1920
// This would happen if maybe instructor deletes student project at
1921
// the exact wrong time.
1922
// TODO: just create a new project for them?
1923
throw Error("student has no project, so can't run nbgrader");
1924
}
1925
grade_project_id = student_project_id;
1926
// grade right where student did their work.
1927
student_path = assignment.get("target_path");
1928
}
1929
1930
const where_grade =
1931
redux.getStore("projects").get_title(grade_project_id) ?? "a project";
1932
1933
const project_name = nbgrader_grade_project
1934
? `project ${trunc(where_grade, 40)}`
1935
: `${store.get_student_name(student_id)}'s project`;
1936
1937
if (instructor_ipynb_files == null) {
1938
instructor_ipynb_files = await this.nbgrader_instructor_ipynb_files(
1939
assignment_id,
1940
);
1941
if (this.course_actions.is_closed()) return;
1942
}
1943
if (len(instructor_ipynb_files) == 0) {
1944
/* console.log(
1945
"run_nbgrader_for_one_student",
1946
assignment_id,
1947
student_id,
1948
"done -- no ipynb files"
1949
); */
1950
return; // nothing to do
1951
}
1952
1953
const result: { [path: string]: any } = {};
1954
const scores: { [filename: string]: NotebookScores | string } = {};
1955
1956
const one_file: (file: string) => Promise<void> = async (file) => {
1957
const activity_id = this.course_actions.set_activity({
1958
desc: `Running nbgrader on ${store.get_student_name(
1959
student_id,
1960
)}'s "${file}" in '${trunc(where_grade, 40)}'`,
1961
});
1962
if (assignment == null || student == null) {
1963
// This won't happen, but it makes Typescript happy.
1964
return;
1965
}
1966
try {
1967
// fullpath = where their collected work is.
1968
const fullpath = join(
1969
assignment.get("collect_path"),
1970
student.get("student_id"),
1971
file,
1972
);
1973
const student_ipynb: string = await jupyter_strip_notebook(
1974
course_project_id,
1975
fullpath,
1976
);
1977
if (instructor_ipynb_files == null) throw Error("BUG");
1978
const instructor_ipynb: string = instructor_ipynb_files[file];
1979
if (this.course_actions.is_closed()) return;
1980
1981
const id = this.course_actions.set_activity({
1982
desc: `Ensuring ${project_name} is running`,
1983
});
1984
1985
try {
1986
const did_start = await start_project(grade_project_id, 60);
1987
// if *we* started the student project, we'll also stop it afterwards
1988
if (!nbgrader_grade_project) {
1989
stop_student_project = did_start;
1990
}
1991
} finally {
1992
this.course_actions.clear_activity(id);
1993
}
1994
1995
let ephemeralGradePath;
1996
try {
1997
if (
1998
grade_project_id != course_project_id &&
1999
grade_project_id != student_project_id
2000
) {
2001
ephemeralGradePath = true;
2002
// Make a fresh copy of the assignment files to the grade project.
2003
// This is necessary because grading the assignment may depend on
2004
// data files that are sent as part of the assignment. Also,
2005
// student's might have some code in text files next to the ipynb.
2006
await webapp_client.project_client.copy_path_between_projects({
2007
src_project_id: course_project_id,
2008
src_path: student_path,
2009
target_project_id: grade_project_id,
2010
target_path: student_path,
2011
overwrite_newer: true,
2012
delete_missing: true,
2013
backup: false,
2014
timeout: COPY_TIMEOUT_MS / 1000,
2015
});
2016
} else {
2017
ephemeralGradePath = false;
2018
}
2019
2020
const opts = {
2021
timeout_ms: store.getIn(
2022
["settings", "nbgrader_timeout_ms"],
2023
NBGRADER_TIMEOUT_MS,
2024
),
2025
cell_timeout_ms: store.getIn(
2026
["settings", "nbgrader_cell_timeout_ms"],
2027
NBGRADER_CELL_TIMEOUT_MS,
2028
),
2029
max_output: store.getIn(
2030
["settings", "nbgrader_max_output"],
2031
NBGRADER_MAX_OUTPUT,
2032
),
2033
max_output_per_cell: store.getIn(
2034
["settings", "nbgrader_max_output_per_cell"],
2035
NBGRADER_MAX_OUTPUT_PER_CELL,
2036
),
2037
student_ipynb,
2038
instructor_ipynb,
2039
path: student_path,
2040
project_id: grade_project_id,
2041
};
2042
/*console.log(
2043
student_id,
2044
file,
2045
"about to launch autograding with input ",
2046
opts
2047
);*/
2048
const r = await nbgrader(opts);
2049
/* console.log(student_id, "autograding finished successfully", {
2050
file,
2051
r,
2052
});*/
2053
result[file] = r;
2054
} finally {
2055
if (ephemeralGradePath) {
2056
await webapp_client.project_client.exec({
2057
project_id: grade_project_id,
2058
command: "rm",
2059
args: ["-rf", student_path],
2060
});
2061
}
2062
}
2063
2064
if (!nbgrader_grade_project && stop_student_project) {
2065
const idstop = this.course_actions.set_activity({
2066
desc: `Stopping project ${project_name} after grading.`,
2067
});
2068
try {
2069
await stop_project(grade_project_id, 60);
2070
} finally {
2071
this.course_actions.clear_activity(idstop);
2072
}
2073
}
2074
} catch (err) {
2075
// console.log("nbgrader failed", { student_id, file, err });
2076
scores[file] = `${err}`;
2077
} finally {
2078
this.course_actions.clear_activity(activity_id);
2079
}
2080
};
2081
2082
// NOTE: we *could* run multiple files in parallel, but that causes
2083
// trouble for very little benefit. It's better to run across all students in parallel,
2084
// and the trouble is just that running lots of code in the same project can confuse
2085
// the backend api and use extra memory (which is unfair to students being graded, e.g.,
2086
// if their project has 1GB of RAM and we run 3 notebooks at once, they get "gypped").
2087
try {
2088
this.nbgrader_set_is_running(assignment_id, student_id);
2089
2090
for (const file in instructor_ipynb_files) {
2091
await one_file(file);
2092
}
2093
} finally {
2094
this.nbgrader_set_is_done(assignment_id, student_id);
2095
}
2096
/* console.log("ran nbgrader for all files for a student", {
2097
student_id,
2098
result
2099
}); */
2100
// Save any previous nbgrader scores for this student, so we can
2101
// preserve any manually entered scores, rather than overwrite them.
2102
const prev_scores = store.get_nbgrader_scores(assignment_id, student_id);
2103
2104
const nbgrader_score_ids: { [filename: string]: string[] } = {};
2105
2106
for (const filename in result) {
2107
const r = result[filename];
2108
if (r == null) continue;
2109
if (r.output == null) continue;
2110
if (r.ids != null) {
2111
nbgrader_score_ids[filename] = r.ids;
2112
}
2113
2114
// Depending on instructor options, write the graded version of
2115
// the notebook to disk, so the student can see why their grade
2116
// is what it is:
2117
const notebook = JSON.parse(r.output);
2118
scores[filename] = extract_auto_scores(notebook);
2119
if (
2120
prev_scores != null &&
2121
prev_scores[filename] != null &&
2122
typeof prev_scores[filename] != "string"
2123
) {
2124
// preserve any manual scores. cast since for some reason the typeof above isn't enough.
2125
for (const id in prev_scores[filename] as object) {
2126
const x = prev_scores[filename][id];
2127
if (x.manual && x.score && scores[filename][id] != null) {
2128
scores[filename][id].score = x.score;
2129
}
2130
}
2131
}
2132
2133
if (!nbgrader_include_hidden_tests) {
2134
// IMPORTANT: this *must* happen after extracting scores above!
2135
// Otherwise students get perfect grades.
2136
ipynb_clear_hidden_tests(notebook);
2137
}
2138
2139
await this.write_autograded_notebook(
2140
assignment,
2141
student_id,
2142
filename,
2143
JSON.stringify(notebook, undefined, 2),
2144
);
2145
}
2146
2147
this.set_nbgrader_scores_for_one_student(
2148
assignment_id,
2149
student_id,
2150
scores,
2151
nbgrader_score_ids,
2152
commit,
2153
);
2154
};
2155
2156
autograded_path = (
2157
assignment: AssignmentRecord,
2158
student_id: string,
2159
filename: string,
2160
): string => {
2161
return autograded_filename(
2162
join(assignment.get("collect_path"), student_id, filename),
2163
);
2164
};
2165
2166
private write_autograded_notebook = async (
2167
assignment: AssignmentRecord,
2168
student_id: string,
2169
filename: string,
2170
content: string,
2171
): Promise<void> => {
2172
const path = this.autograded_path(assignment, student_id, filename);
2173
await this.write_text_file_to_course_project({ path, content });
2174
};
2175
2176
open_file_in_collected_assignment = async (
2177
assignment_id: string,
2178
student_id: string,
2179
file: string,
2180
): Promise<void> => {
2181
const { assignment, student, store } = this.course_actions.resolve({
2182
assignment_id,
2183
student_id,
2184
});
2185
if (assignment == null || student == null) {
2186
throw Error("no such student or assignment");
2187
}
2188
const course_project_id = store.get("course_project_id");
2189
const fullpath = join(
2190
assignment.get("collect_path"),
2191
student.get("student_id"),
2192
file,
2193
);
2194
2195
await redux
2196
.getProjectActions(course_project_id)
2197
.open_file({ path: fullpath, foreground: true });
2198
};
2199
2200
private nbgrader_set_is_running = (
2201
assignment_id: string,
2202
student_id?: string,
2203
): void => {
2204
const store = this.get_store();
2205
let nbgrader_run_info: NBgraderRunInfo = store.get(
2206
"nbgrader_run_info",
2207
Map(),
2208
);
2209
const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;
2210
nbgrader_run_info = nbgrader_run_info.set(key, webapp_client.server_time());
2211
this.course_actions.setState({ nbgrader_run_info });
2212
};
2213
2214
private nbgrader_set_is_done = (
2215
assignment_id: string,
2216
student_id?: string,
2217
): void => {
2218
const store = this.get_store();
2219
let nbgrader_run_info: NBgraderRunInfo = store.get(
2220
"nbgrader_run_info",
2221
Map<string, number>(),
2222
);
2223
const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;
2224
nbgrader_run_info = nbgrader_run_info.delete(key);
2225
this.course_actions.setState({ nbgrader_run_info });
2226
};
2227
2228
export_file_use_times = async (
2229
assignment_id: string,
2230
json_filename: string,
2231
): Promise<void> => {
2232
// Get the path of the assignment
2233
const { assignment, store } = this.course_actions.resolve({
2234
assignment_id,
2235
});
2236
if (assignment == null) {
2237
throw Error("no such assignment");
2238
}
2239
const src_path = this.assignment_src_path(assignment);
2240
const target_path = assignment.get("path");
2241
await export_student_file_use_times(
2242
store.get("course_project_id"),
2243
src_path,
2244
target_path,
2245
store.get("students"),
2246
json_filename,
2247
store.get_student_name.bind(store),
2248
);
2249
};
2250
2251
export_collected = async (assignment_id: string): Promise<void> => {
2252
const set_activity = this.course_actions.set_activity.bind(
2253
this.course_actions,
2254
);
2255
const id = set_activity({
2256
desc: "Exporting collected files...",
2257
});
2258
try {
2259
const { assignment, store } = this.course_actions.resolve({
2260
assignment_id,
2261
});
2262
if (assignment == null) return;
2263
const students = store.get("students");
2264
const src_path = this.assignment_src_path(assignment);
2265
const collect_path = assignment.get("collect_path");
2266
const i = store.get("course_filename").lastIndexOf(".");
2267
const base_export_path =
2268
store.get("course_filename").slice(0, i) + "-export";
2269
const export_path = join(base_export_path, src_path);
2270
2271
const student_name = function (student_id: string): string {
2272
const v = split(store.get_student_name(student_id));
2273
return v.join("_");
2274
};
2275
2276
const activity = function (s: string): void {
2277
set_activity({
2278
id,
2279
desc: "Exporting collected files... " + s,
2280
});
2281
};
2282
2283
const project_id = store.get("course_project_id");
2284
2285
await export_assignment(
2286
project_id,
2287
collect_path,
2288
export_path,
2289
students,
2290
student_name,
2291
activity,
2292
);
2293
2294
redux.getProjectActions(project_id).open_directory(base_export_path);
2295
} catch (err) {
2296
this.course_actions.set_error(
2297
`Error exporting collected student files -- ${err}`,
2298
);
2299
} finally {
2300
set_activity({ id });
2301
}
2302
};
2303
}
2304
2305