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