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/store.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
// React libraries
7
import { Store, redux } from "@cocalc/frontend/app-framework";
8
import { site_license_public_info } from "@cocalc/frontend/site-licenses/util";
9
// CoCalc libraries
10
import { cmp, cmp_array, set } from "@cocalc/util/misc";
11
import { DirectoryListingEntry } from "@cocalc/util/types";
12
// Course Library
13
import { STEPS } from "./util";
14
import { Map, Set, List } from "immutable";
15
import { TypedMap, createTypedMap } from "@cocalc/frontend/app-framework";
16
import { SITE_NAME } from "@cocalc/util/theme";
17
// Upgrades
18
import * as project_upgrades from "./project-upgrades";
19
import {
20
Datastore,
21
EnvVars,
22
EnvVarsRecord,
23
} from "@cocalc/frontend/projects/actions";
24
import { StudentProjectFunctionality } from "./configuration/customize-student-project-functionality";
25
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
26
import type {
27
CopyConfigurationOptions,
28
CopyConfigurationTargets,
29
} from "./configuration/configuration-copying";
30
31
export const PARALLEL_DEFAULT = 5;
32
export const MAX_COPY_PARALLEL = 25;
33
34
import {
35
AssignmentCopyStep,
36
AssignmentStatus,
37
SiteLicenseStrategy,
38
UpgradeGoal,
39
} from "./types";
40
41
import { NotebookScores } from "../jupyter/nbgrader/autograde";
42
43
import { CourseActions } from "./actions";
44
45
export const DEFAULT_LICENSE_UPGRADE_HOST_PROJECT = false;
46
47
export type TerminalCommandOutput = TypedMap<{
48
project_id: string;
49
stdout?: string;
50
stderr?: string;
51
time_ms?: number;
52
}>;
53
54
export type TerminalCommand = TypedMap<{
55
input?: string;
56
output?: List<TerminalCommandOutput>;
57
running?: boolean;
58
}>;
59
60
export type StudentRecord = TypedMap<{
61
create_project?: number; // Time the student project was created
62
account_id?: string;
63
student_id: string;
64
first_name?: string;
65
last_name?: string;
66
last_active?: number;
67
hosting?: string;
68
email_address?: string;
69
project_id?: string;
70
deleted?: boolean;
71
// deleted_account: true if the account_id is known to have been deleted
72
deleted_account?: boolean;
73
note?: string;
74
last_email_invite?: number;
75
}>;
76
77
export type StudentsMap = Map<string, StudentRecord>;
78
79
export type LastCopyInfo = {
80
time?: number;
81
error?: string;
82
start?: number;
83
};
84
85
export type AssignmentRecord = TypedMap<{
86
assignment_id: string;
87
deleted: boolean;
88
due_date: string; // iso string
89
path: string;
90
peer_grade?: {
91
enabled: boolean;
92
due_date: number;
93
map: { [student_id: string]: string[] }; // map from student_id to *who* will grade that student
94
};
95
note: string;
96
97
last_assignment?: { [student_id: string]: LastCopyInfo };
98
last_collect?: { [student_id: string]: LastCopyInfo };
99
last_peer_assignment?: { [student_id: string]: LastCopyInfo };
100
last_peer_collect?: { [student_id: string]: LastCopyInfo };
101
last_return_graded?: { [student_id: string]: LastCopyInfo };
102
103
skip_assignment: boolean;
104
skip_collect: boolean;
105
skip_grading: boolean;
106
target_path: string;
107
collect_path: string;
108
graded_path: string;
109
110
nbgrader?: boolean; // if true, probably includes at least one nbgrader ipynb file
111
listing?: DirectoryListingEntry[];
112
113
grades?: { [student_id: string]: string };
114
comments?: { [student_id: string]: string };
115
nbgrader_scores?: {
116
[student_id: string]: { [ipynb: string]: NotebookScores | string };
117
};
118
nbgrader_score_ids?: { [ipynb: string]: string[] };
119
}>;
120
121
export type AssignmentsMap = Map<string, AssignmentRecord>;
122
123
export type HandoutRecord = TypedMap<{
124
deleted: boolean;
125
handout_id: string;
126
target_path: string;
127
path: string;
128
note: string;
129
status: { [student_id: string]: LastCopyInfo };
130
}>;
131
132
export type HandoutsMap = Map<string, HandoutRecord>;
133
134
export type SortDescription = TypedMap<{
135
column_name: string;
136
is_descending: boolean;
137
}>;
138
139
export type CourseSettingsRecord = TypedMap<{
140
allow_collabs: boolean;
141
student_project_functionality?: StudentProjectFunctionality;
142
description: string;
143
email_invite: string;
144
institute_pay: boolean;
145
pay: string | Date;
146
payInfo?: TypedMap<PurchaseInfo>;
147
payCost?: number;
148
shared_project_id: string;
149
student_pay: boolean;
150
title: string;
151
upgrade_goal: Map<any, any>;
152
license_upgrade_host_project?: boolean; // https://github.com/sagemathinc/cocalc/issues/5360
153
site_license_id?: string;
154
site_license_removed?: string; // comma separated list of licenses that have been explicitly removed from this course.
155
site_license_strategy?: SiteLicenseStrategy;
156
copy_parallel?: number;
157
nbgrader_grade_in_instructor_project?: boolean; // deprecated
158
nbgrader_grade_project?: string;
159
nbgrader_include_hidden_tests?: boolean;
160
nbgrader_cell_timeout_ms?: number;
161
nbgrader_timeout_ms?: number;
162
nbgrader_max_output?: number;
163
nbgrader_max_output_per_cell?: number;
164
nbgrader_parallel?: number;
165
datastore?: Datastore;
166
envvars?: EnvVarsRecord;
167
copy_config_targets: CopyConfigurationTargets;
168
copy_config_options: CopyConfigurationOptions;
169
}>;
170
171
export const CourseSetting = createTypedMap<CourseSettingsRecord>();
172
173
export type IsGradingMap = Map<string, boolean>;
174
175
export type ActivityMap = Map<number, string>;
176
177
// This NBgraderRunInfo is a map from what nbgrader task is running
178
// to when it was started (ms since epoch). The keys are as follows:
179
// 36-character [account_id] = means that entire assignment with that id is being graded
180
// [account_id]-[student_id] = the particular assignment for that student is being graded
181
// We do not track grading of individual files in an assignment.
182
// This is NOT sync'd across users, since that would increase network traffic and
183
// is probably not critical to do, since the worst case scenario is just running nbgrader
184
// more than once at the same time, which is probably just *inefficient*.
185
export type NBgraderRunInfo = Map<string, number>;
186
187
export interface CourseState {
188
activity: ActivityMap;
189
action_all_projects_state: string;
190
active_student_sort: { column_name: string; is_descending: boolean };
191
active_assignment_sort: { column_name: string; is_descending: boolean };
192
assignments: AssignmentsMap;
193
course_filename: string;
194
course_project_id: string;
195
configuring_projects?: boolean;
196
reinviting_students?: boolean;
197
error?: string;
198
expanded_students: Set<string>;
199
expanded_assignments: Set<string>;
200
expanded_peer_configs: Set<string>;
201
expanded_handouts: Set<string>;
202
expanded_skip_gradings: Set<string>;
203
active_feedback_edits: IsGradingMap;
204
handouts: HandoutsMap;
205
loading: boolean; // initially loading the syncdoc from disk.
206
saving: boolean;
207
settings: CourseSettingsRecord;
208
show_save_button: boolean;
209
students: StudentsMap;
210
unsaved?: boolean;
211
terminal_command?: TerminalCommand;
212
nbgrader_run_info?: NBgraderRunInfo;
213
// map from student_id to a filter string.
214
assignmentFilter?: Map<string, string>;
215
// each page -- students, assignments, handouts (etc.?) has a filter. This is the state of that filter.
216
pageFilter?: Map<string, string>;
217
}
218
219
export class CourseStore extends Store<CourseState> {
220
private assignment_status_cache?: {
221
[assignment_id: string]: AssignmentStatus;
222
};
223
private handout_status_cache?: {
224
[key: string]: { handout: number; not_handout: number };
225
};
226
227
// Return true if there are any non-deleted assignments that use peer grading
228
public any_assignment_uses_peer_grading(): boolean {
229
for (const [, assignment] of this.get_assignments()) {
230
if (
231
assignment.getIn(["peer_grade", "enabled"]) &&
232
!assignment.get("deleted")
233
) {
234
return true;
235
}
236
}
237
return false;
238
}
239
240
// Return Javascript array of the student_id's of the students
241
// that graded the given student, or undefined if no relevant assignment.
242
public get_peers_that_graded_student(
243
assignment_id: string,
244
student_id: string,
245
): string[] {
246
const peers: string[] = [];
247
const assignment = this.get_assignment(assignment_id);
248
if (assignment == null) return peers;
249
const map = assignment.getIn(["peer_grade", "map"]);
250
if (map == null) {
251
return peers;
252
}
253
for (const [other_student_id, who_grading] of map) {
254
if (who_grading.includes(student_id)) {
255
peers.push(other_student_id as string); // typescript thinks it could be a number?
256
}
257
}
258
return peers;
259
}
260
261
public get_shared_project_id(): string | undefined {
262
// return project_id (a string) if shared project has been created,
263
// or undefined or empty string otherwise.
264
return this.getIn(["settings", "shared_project_id"]);
265
}
266
267
public get_pay(): string | Date {
268
const settings = this.get("settings");
269
if (settings == null || !settings.get("student_pay")) return "";
270
const pay = settings.get("pay");
271
if (!pay) return "";
272
return pay;
273
}
274
275
public get_payInfo(): PurchaseInfo | null {
276
const settings = this.get("settings");
277
if (settings == null || !settings.get("student_pay")) return null;
278
const payInfo = settings.get("payInfo")?.toJS();
279
if (!payInfo) return null;
280
return payInfo;
281
}
282
283
public get_datastore(): Datastore {
284
const settings = this.get("settings");
285
if (settings == null || settings.get("datastore") == null) return undefined;
286
const ds = settings.get("datastore");
287
if (typeof ds === "boolean" || Array.isArray(ds)) {
288
return ds;
289
} else {
290
console.warn(`course/get_datastore: encountered faulty value:`, ds);
291
return undefined;
292
}
293
}
294
295
public get_envvars(): EnvVars | undefined {
296
const envvars: unknown = this.getIn(["settings", "envvars"]);
297
if (envvars == null) return undefined;
298
if (typeof (envvars as any)?.toJS === "function") {
299
return (envvars as any).toJS();
300
} else {
301
console.warn(`course/get_envvars: encountered faulty value:`, envvars);
302
return;
303
}
304
}
305
306
public get_allow_collabs(): boolean {
307
return !!this.getIn(["settings", "allow_collabs"]);
308
}
309
310
public get_email_invite(): string {
311
const invite = this.getIn(["settings", "email_invite"]);
312
if (invite) return invite;
313
const site_name = redux.getStore("customize").get("site_name") ?? SITE_NAME;
314
return `Hello!\n\nWe will use ${site_name} for the course *{title}*.\n\nPlease sign up!\n\n--\n\n{name}`;
315
}
316
317
public get_students(): StudentsMap {
318
return this.get("students");
319
}
320
321
// Return the student's name as a string, using a
322
// bunch of heuristics to try to present the best
323
// reasonable name, given what we know. For example,
324
// it uses an instructor-given custom name if it was set.
325
public get_student_name(student_id: string): string {
326
const { student } = this.resolve({ student_id });
327
if (student == null) {
328
// Student does not exist at all in store -- this shouldn't happen
329
return "Unknown Student";
330
}
331
// Try instructor assigned name:
332
if (student.get("first_name")?.trim() || student.get("last_name")?.trim()) {
333
return [
334
student.get("first_name", "")?.trim(),
335
student.get("last_name", "")?.trim(),
336
].join(" ");
337
}
338
const account_id = student.get("account_id");
339
if (account_id == null) {
340
// Student doesn't have an account yet on CoCalc (that we know about).
341
// Email address:
342
if (student.has("email_address")) {
343
return student.get("email_address")!;
344
}
345
// One of the above had to work, since we add students by email or account.
346
// But put this in anyways:
347
return "Unknown Student";
348
}
349
// Now we have a student with a known CoCalc account.
350
// We would have returned early above if there was an instructor assigned name,
351
// so we just return their name from cocalc, if known.
352
const users = this.redux.getStore("users");
353
if (users == null) throw Error("users must be defined");
354
const name = users.get_name(account_id);
355
if (name?.trim()) return name;
356
// This situation usually shouldn't happen, but maybe could in case the user was known but
357
// then removed themselves as a collaborator, or something else odd.
358
if (student.has("email_address")) {
359
return student.get("email_address")!;
360
}
361
// OK, now there is really no way to identify this student. I suppose this could
362
// happen if the student was added by searching for their name, then they removed
363
// themselves. Nothing useful we can do at this point.
364
return "Unknown Student";
365
}
366
367
// Returns student name as with get_student_name above,
368
// but also include an email address in angle braces,
369
// if one is known in a full version of the name.
370
// This is purely meant to provide a bit of extra info
371
// for the instructor, and not actually used to send emails.
372
public get_student_name_extra(student_id: string): {
373
simple: string;
374
full: string;
375
} {
376
const { student } = this.resolve({ student_id });
377
if (student == null) {
378
return { simple: "Unknown", full: "Unknown Student" };
379
}
380
const email = student.get("email_address");
381
const simple = this.get_student_name(student_id);
382
let extra: string = "";
383
if (
384
(student.has("first_name") || student.has("last_name")) &&
385
student.has("account_id")
386
) {
387
const users = this.redux.getStore("users");
388
if (users != null) {
389
const name = users.get_name(student.get("account_id"));
390
if (name != null) {
391
extra = ` (You call them "${student.has("first_name")} ${student.has(
392
"last_name",
393
)}", but they call themselves "${name}".)`;
394
}
395
}
396
}
397
return { simple, full: email ? `${simple} <${email}>${extra}` : simple };
398
}
399
400
// Return a name that should sort in a sensible way in
401
// alphabetical order. This is mainly used for CSV export,
402
// and is not something that will ever get looked at.
403
public get_student_sort_name(student_id: string): string {
404
const { student } = this.resolve({ student_id });
405
if (student == null) {
406
return student_id; // keeps the sort stable
407
}
408
if (student.has("first_name") || student.has("last_name")) {
409
return [student.get("last_name", ""), student.get("first_name", "")].join(
410
" ",
411
);
412
}
413
const account_id = student.get("account_id");
414
if (account_id == null) {
415
if (student.has("email_address")) {
416
return student.get("email_address")!;
417
}
418
return student_id;
419
}
420
const users = this.redux.getStore("users");
421
if (users == null) return student_id;
422
return [
423
users.get_last_name(account_id),
424
users.get_first_name(account_id),
425
].join(" ");
426
}
427
428
public get_student_email(student_id: string): string {
429
return this.getIn(["students", student_id, "email_address"], "");
430
}
431
432
public get_student_ids(opts: { deleted?: boolean } = {}): string[] {
433
const v: string[] = [];
434
opts.deleted = !!opts.deleted;
435
for (const [student_id, val] of this.get("students")) {
436
if (!!val.get("deleted") == opts.deleted) {
437
v.push(student_id);
438
}
439
}
440
return v;
441
}
442
443
// return list of all student projects
444
public get_student_project_ids(
445
opts: {
446
include_deleted?: boolean;
447
deleted_only?: boolean;
448
} = {},
449
): string[] {
450
// include_deleted = if true, also include deleted projects
451
// deleted_only = if true, only include deleted projects
452
const { include_deleted, deleted_only } = opts;
453
454
let v: string[] = [];
455
456
for (const [, val] of this.get("students")) {
457
const project_id = val.get("project_id");
458
if (!project_id) {
459
continue;
460
}
461
if (deleted_only) {
462
if (include_deleted && val.get("deleted")) {
463
v.push(project_id);
464
}
465
} else if (include_deleted) {
466
v.push(project_id);
467
} else if (!val.get("deleted")) {
468
v.push(project_id);
469
}
470
}
471
return v;
472
}
473
474
public get_student(student_id: string): StudentRecord | undefined {
475
// return student with given id
476
return this.getIn(["students", student_id]);
477
}
478
479
public get_student_project_id(student_id: string): string | undefined {
480
return this.getIn(["students", student_id, "project_id"]);
481
}
482
483
// Return a Javascript array of immutable.js StudentRecord maps, sorted
484
// by sort name (so first last name).
485
public get_sorted_students(): StudentRecord[] {
486
const v: StudentRecord[] = [];
487
for (const [, student] of this.get("students")) {
488
if (!student.get("deleted")) {
489
v.push(student);
490
}
491
}
492
v.sort((a, b) =>
493
cmp(
494
this.get_student_sort_name(a.get("student_id")),
495
this.get_student_sort_name(b.get("student_id")),
496
),
497
);
498
return v;
499
}
500
501
public get_grade(assignment_id: string, student_id: string): string {
502
const { assignment } = this.resolve({ assignment_id });
503
if (assignment == null) return "";
504
const r = assignment.getIn(["grades", student_id], "");
505
return r == null ? "" : r;
506
}
507
508
public get_nbgrader_scores(
509
assignment_id: string,
510
student_id: string,
511
): { [ipynb: string]: NotebookScores | string } | undefined {
512
const { assignment } = this.resolve({ assignment_id });
513
return assignment?.getIn(["nbgrader_scores", student_id])?.toJS();
514
}
515
516
public get_nbgrader_score_ids(
517
assignment_id: string,
518
): { [ipynb: string]: string[] } | undefined {
519
const { assignment } = this.resolve({ assignment_id });
520
const ids = assignment?.get("nbgrader_score_ids")?.toJS();
521
if (ids != null) return ids;
522
// TODO: If the score id's aren't known, it would be nice to try
523
// to parse the master ipynb file and compute them. We still
524
// allow for the possibility that this fails and return undefined
525
// in that case. This is painful since it involves async calls
526
// to the backend, and the code that does this as part of grading
527
// is deep inside other functions. The list we return here
528
// is always assumed to be used on a "best effort" basis, so this
529
// is at worst annoying.
530
}
531
532
public get_comments(assignment_id: string, student_id: string): string {
533
const { assignment } = this.resolve({ assignment_id });
534
if (assignment == null) return "";
535
const r = assignment.getIn(["comments", student_id], "");
536
return r == null ? "" : r;
537
}
538
539
public get_due_date(assignment_id: string): Date | undefined {
540
const { assignment } = this.resolve({ assignment_id });
541
if (assignment == null) return;
542
const due_date = assignment.get("due_date");
543
if (due_date != null) {
544
return new Date(due_date);
545
}
546
}
547
548
public get_assignments(): AssignmentsMap {
549
return this.get("assignments");
550
}
551
552
public get_sorted_assignments(): AssignmentRecord[] {
553
const v: AssignmentRecord[] = [];
554
for (const [, assignment] of this.get_assignments()) {
555
if (!assignment.get("deleted")) {
556
v.push(assignment);
557
}
558
}
559
const f = function (a: AssignmentRecord) {
560
return [a.get("due_date", 0), a.get("path", "")];
561
};
562
v.sort((a, b) => cmp_array(f(a), f(b)));
563
return v;
564
}
565
566
// return assignment with given id if a string; otherwise, just return
567
// the latest version of the assignment as stored in the store.
568
public get_assignment(assignment_id: string): AssignmentRecord | undefined {
569
return this.getIn(["assignments", assignment_id]);
570
}
571
572
// if deleted is true return only deleted assignments
573
public get_assignment_ids(opts: { deleted?: boolean } = {}): string[] {
574
const v: string[] = [];
575
for (const [assignment_id, val] of this.get_assignments()) {
576
if (!!val.get("deleted") == opts.deleted) {
577
v.push(assignment_id);
578
}
579
}
580
return v;
581
}
582
583
private num_nondeleted(a): number {
584
let n: number = 0;
585
for (const [, x] of a) {
586
if (!x.get("deleted")) {
587
n += 1;
588
}
589
}
590
return n;
591
}
592
593
// number of non-deleted students
594
public num_students(): number {
595
return this.num_nondeleted(this.get_students());
596
}
597
598
// number of student projects that are currently running
599
public num_running_projects(project_map): number {
600
let n = 0;
601
for (const [, student] of this.get_students()) {
602
if (!student.get("deleted")) {
603
if (
604
project_map.getIn([student.get("project_id"), "state", "state"]) ==
605
"running"
606
) {
607
n += 1;
608
}
609
}
610
}
611
return n;
612
}
613
614
// number of non-deleted assignments
615
public num_assignments(): number {
616
return this.num_nondeleted(this.get_assignments());
617
}
618
619
// number of non-deleted handouts
620
public num_handouts(): number {
621
return this.num_nondeleted(this.get_handouts());
622
}
623
624
// get info about relation between a student and a given assignment
625
public student_assignment_info(
626
student_id: string,
627
assignment_id: string,
628
): {
629
last_assignment?: LastCopyInfo;
630
last_collect?: LastCopyInfo;
631
last_peer_assignment?: LastCopyInfo;
632
last_peer_collect?: LastCopyInfo;
633
last_return_graded?: LastCopyInfo;
634
student_id: string;
635
assignment_id: string;
636
peer_assignment: boolean;
637
peer_collect: boolean;
638
} {
639
const { assignment } = this.resolve({ assignment_id });
640
if (assignment == null) {
641
return {
642
student_id,
643
assignment_id,
644
peer_assignment: false,
645
peer_collect: false,
646
};
647
}
648
649
const status = this.get_assignment_status(assignment_id);
650
if (status == null) throw Error("bug"); // can't happen
651
652
// Important to return undefined if no info -- assumed in code
653
function get_info(field: string): undefined | LastCopyInfo {
654
if (assignment == null) throw Error("bug"); // can't happen
655
const x = assignment.getIn([field, student_id]);
656
if (x == null) return;
657
return (x as any).toJS();
658
}
659
660
const peer_assignment =
661
status.not_collect + status.not_assignment == 0 && status.collect != 0;
662
const peer_collect =
663
status.not_peer_assignment != null && status.not_peer_assignment == 0;
664
665
return {
666
last_assignment: get_info("last_assignment"),
667
last_collect: get_info("last_collect"),
668
last_peer_assignment: get_info("last_peer_assignment"),
669
last_peer_collect: get_info("last_peer_collect"),
670
last_return_graded: get_info("last_return_graded"),
671
student_id,
672
assignment_id,
673
peer_assignment,
674
peer_collect,
675
};
676
}
677
678
// Return true if the assignment was copied to/from the
679
// student, in the given step of the workflow.
680
// Even an attempt to copy with an error counts,
681
// unless no_error is true, in which case it doesn't.
682
public last_copied(
683
step: AssignmentCopyStep,
684
assignment_id: string,
685
student_id: string,
686
no_error?: boolean,
687
): boolean {
688
const x = this.getIn([
689
"assignments",
690
assignment_id,
691
`last_${step}`,
692
student_id,
693
]);
694
if (x == null) {
695
return false;
696
}
697
const y: TypedMap<LastCopyInfo> = x;
698
if (no_error && y.get("error")) {
699
return false;
700
}
701
return y.get("time") != null;
702
}
703
704
public has_grade(assignment_id: string, student_id: string): boolean {
705
return !!this.getIn(["assignments", assignment_id, "grades", student_id]);
706
}
707
708
public get_assignment_status(
709
assignment_id: string,
710
): AssignmentStatus | undefined {
711
//
712
// Compute and return an object that has fields (deleted students are ignored)
713
//
714
// assignment - number of students who have received assignment includes
715
// all students if skip_assignment is true
716
// not_assignment - number of students who have NOT received assignment
717
// always 0 if skip_assignment is true
718
// collect - number of students from whom we have collected assignment includes
719
// all students if skip_collect is true
720
// not_collect - number of students from whom we have NOT collected assignment but we sent it to them
721
// always 0 if skip_assignment is true
722
// peer_assignment - number of students who have received peer assignment
723
// (only present if peer grading enabled; similar for peer below)
724
// not_peer_assignment - number of students who have NOT received peer assignment
725
// peer_collect - number of students from whom we have collected peer grading
726
// not_peer_collect - number of students from whome we have NOT collected peer grading
727
// return_graded - number of students to whom we've returned assignment
728
// not_return_graded - number of students to whom we've NOT returned assignment
729
// but we collected it from them *and* either assigned a grade or skip grading
730
//
731
// This function caches its result and only recomputes values when the store changes,
732
// so it should be safe to call in render.
733
//
734
if (this.assignment_status_cache == null) {
735
this.assignment_status_cache = {};
736
this.on("change", () => {
737
// clear cache on any change to the store
738
this.assignment_status_cache = {};
739
});
740
}
741
const { assignment } = this.resolve({ assignment_id });
742
if (assignment == null) {
743
return;
744
}
745
746
if (this.assignment_status_cache[assignment_id] != null) {
747
// we have cached info
748
return this.assignment_status_cache[assignment_id];
749
}
750
751
const students: string[] = this.get_student_ids({ deleted: false });
752
753
// Is peer grading enabled?
754
const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);
755
const skip_grading: boolean = assignment.get("skip_grading", false);
756
757
const obj: any = {};
758
for (const t of STEPS(peer)) {
759
obj[t] = 0;
760
obj[`not_${t}`] = 0;
761
}
762
const info: AssignmentStatus = obj;
763
for (const student_id of students) {
764
let previous: boolean = true;
765
for (const t of STEPS(peer)) {
766
const x = assignment.getIn([`last_${t}`, student_id]) as
767
| undefined
768
| TypedMap<LastCopyInfo>;
769
if (
770
(x != null && !x.get("error") && !x.get("start")) ||
771
assignment.get(`skip_${t}`)
772
) {
773
previous = true;
774
info[t] += 1;
775
} else {
776
// add 1 only if the previous step *was* done (and in
777
// the case of returning, they have a grade)
778
const graded =
779
this.has_grade(assignment_id, student_id) || skip_grading;
780
if ((previous && t !== "return_graded") || graded) {
781
info[`not_${t}`] += 1;
782
}
783
previous = false;
784
}
785
}
786
}
787
788
this.assignment_status_cache[assignment_id] = info;
789
return info;
790
}
791
792
public get_handouts(): HandoutsMap {
793
return this.get("handouts");
794
}
795
796
public get_handout(handout_id: string): HandoutRecord | undefined {
797
return this.getIn(["handouts", handout_id]);
798
}
799
800
public get_handout_ids(opts: { deleted?: boolean } = {}): string[] {
801
const v: string[] = [];
802
for (const [handout_id, val] of this.get_handouts()) {
803
if (!!val.get("deleted") == opts.deleted) {
804
v.push(handout_id);
805
}
806
}
807
return v;
808
}
809
810
public student_handout_info(
811
student_id: string,
812
handout_id: string,
813
): { status?: LastCopyInfo; handout_id: string; student_id: string } {
814
// status -- important to be undefined if no info -- assumed in code
815
const status = this.getIn(["handouts", handout_id, "status", student_id]);
816
return {
817
status: status != null ? status.toJS() : undefined,
818
student_id,
819
handout_id,
820
};
821
}
822
823
// Return the last time the handout was copied to/from the
824
// student (in the given step of the workflow), or undefined.
825
// Even an attempt to copy with an error counts.
826
public handout_last_copied(handout_id: string, student_id: string): boolean {
827
const x = this.getIn(["handouts", handout_id, "status", student_id]) as
828
| TypedMap<LastCopyInfo>
829
| undefined;
830
if (x == null) {
831
return false;
832
}
833
if (x.get("error")) {
834
return false;
835
}
836
return x.get("time") != null;
837
}
838
839
public get_handout_status(
840
handout_id: string,
841
): undefined | { handout: number; not_handout: number } {
842
//
843
// Compute and return an object that has fields (deleted students are ignored)
844
//
845
// handout - number of students who have received handout
846
// not_handout - number of students who have NOT received handout
847
// This function caches its result and only recomputes values when the store changes,
848
// so it should be safe to call in render.
849
//
850
if (this.handout_status_cache == null) {
851
this.handout_status_cache = {};
852
this.on("change", () => {
853
// clear cache on any change to the store
854
this.handout_status_cache = {};
855
});
856
}
857
const { handout } = this.resolve({ handout_id });
858
if (handout == null) {
859
return undefined;
860
}
861
862
if (this.handout_status_cache[handout_id] != null) {
863
return this.handout_status_cache[handout_id];
864
}
865
866
const students: string[] = this.get_student_ids({ deleted: false });
867
868
const info = {
869
handout: 0,
870
not_handout: 0,
871
};
872
873
const status = handout.get("status");
874
for (const student_id of students) {
875
if (status == null) {
876
info.not_handout += 1;
877
} else {
878
const x = status.get(student_id);
879
if (x != null && !x.get("error")) {
880
info.handout += 1;
881
} else {
882
info.not_handout += 1;
883
}
884
}
885
}
886
887
this.handout_status_cache[handout_id] = info;
888
return info;
889
}
890
891
public get_upgrade_plan(upgrade_goal: UpgradeGoal) {
892
const account_store: any = this.redux.getStore("account");
893
const project_map = this.redux.getStore("projects").get("project_map");
894
if (project_map == null) throw Error("not fully loaded");
895
const plan = project_upgrades.upgrade_plan({
896
account_id: account_store.get_account_id(),
897
purchased_upgrades: account_store.get_total_upgrades(),
898
project_map,
899
student_project_ids: set(
900
this.get_student_project_ids({
901
include_deleted: true,
902
}),
903
),
904
deleted_project_ids: set(
905
this.get_student_project_ids({
906
include_deleted: true,
907
deleted_only: true,
908
}),
909
),
910
upgrade_goal,
911
});
912
return plan;
913
}
914
915
private resolve(opts: {
916
assignment_id?: string;
917
student_id?: string;
918
handout_id?: string;
919
}): {
920
student?: StudentRecord;
921
assignment?: AssignmentRecord;
922
handout?: HandoutRecord;
923
} {
924
const actions = this.redux.getActions(this.name);
925
if (actions == null) return {};
926
const x = (actions as CourseActions).resolve(opts);
927
delete (x as any).store;
928
return x;
929
}
930
931
// List of ids of (non-deleted) assignments that have been
932
// assigned to at least one student.
933
public get_assigned_assignment_ids(): string[] {
934
const v: string[] = [];
935
for (const [assignment_id, val] of this.get_assignments()) {
936
if (val.get("deleted")) continue;
937
const x = val.get(`last_assignment`);
938
if (x != null && x.size > 0) {
939
v.push(assignment_id);
940
}
941
}
942
return v;
943
}
944
945
// List of ids of (non-deleted) handouts that have been copied
946
// out to at least one student.
947
public get_assigned_handout_ids(): string[] {
948
const v: string[] = [];
949
for (const [handout_id, val] of this.get_handouts()) {
950
if (val.get("deleted")) continue;
951
const x = val.get(`status`);
952
if (x != null && x.size > 0) {
953
v.push(handout_id);
954
}
955
}
956
return v;
957
}
958
959
public get_copy_parallel(): number {
960
const n = this.getIn(["settings", "copy_parallel"]) ?? PARALLEL_DEFAULT;
961
if (n < 1) return 1;
962
if (n > MAX_COPY_PARALLEL) return MAX_COPY_PARALLEL;
963
return n;
964
}
965
966
public get_nbgrader_parallel(): number {
967
const n = this.getIn(["settings", "nbgrader_parallel"]) ?? PARALLEL_DEFAULT;
968
if (n < 1) return 1;
969
if (n > 50) return 50;
970
return n;
971
}
972
973
public async getLicenses(force?: boolean): Promise<{
974
[license_id: string]: { expired: boolean; runLimit: number };
975
}> {
976
const licenses: {
977
[license_id: string]: { expired: boolean; runLimit: number };
978
} = {};
979
const license_ids = this.getIn(["settings", "site_license_id"]) ?? "";
980
for (const license_id of license_ids.split(",")) {
981
if (!license_id) continue;
982
try {
983
const license_info = await site_license_public_info(license_id, force);
984
if (license_info == null) continue;
985
const { expires, run_limit } = license_info;
986
const expired = !!(expires && expires <= new Date());
987
const runLimit = run_limit ? run_limit : 999999999999999; // effectively unlimited
988
licenses[license_id] = { expired, runLimit };
989
} catch (err) {
990
console.warn(`Error getting license info for ${license_id}`, err);
991
}
992
}
993
return licenses;
994
}
995
}
996
997
export function get_nbgrader_score(scores: {
998
[ipynb: string]: NotebookScores | string;
999
}): { score: number; points: number; error?: boolean; manual_needed: boolean } {
1000
let points: number = 0;
1001
let score: number = 0;
1002
let error: boolean = false;
1003
let manual_needed: boolean = false;
1004
for (const ipynb in scores) {
1005
const x = scores[ipynb];
1006
if (typeof x == "string") {
1007
error = true;
1008
continue;
1009
}
1010
for (const grade_id in x) {
1011
const y = x[grade_id];
1012
if (y.score == null && y.manual) {
1013
manual_needed = true;
1014
}
1015
if (y.score) {
1016
score += y.score;
1017
}
1018
points += y.points;
1019
}
1020
}
1021
return { score, points, error, manual_needed };
1022
}
1023
1024