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