Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/course/store.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// React libraries6import { Store, redux } from "@cocalc/frontend/app-framework";7import { site_license_public_info } from "@cocalc/frontend/site-licenses/util";8// CoCalc libraries9import { cmp, cmp_array, set } from "@cocalc/util/misc";10import { DirectoryListingEntry } from "@cocalc/util/types";11// Course Library12import { STEPS } from "./util";13import { Map, Set, List } from "immutable";14import { TypedMap, createTypedMap } from "@cocalc/frontend/app-framework";15import { SITE_NAME } from "@cocalc/util/theme";16// Upgrades17import * as project_upgrades from "./project-upgrades";18import {19Datastore,20EnvVars,21EnvVarsRecord,22} from "@cocalc/frontend/projects/actions";23import { StudentProjectFunctionality } from "./configuration/customize-student-project-functionality";24import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";25import type {26CopyConfigurationOptions,27CopyConfigurationTargets,28} from "./configuration/configuration-copying";2930export const PARALLEL_DEFAULT = 5;31export const MAX_COPY_PARALLEL = 25;3233import {34AssignmentCopyStep,35AssignmentStatus,36SiteLicenseStrategy,37UpgradeGoal,38} from "./types";3940import { NotebookScores } from "../jupyter/nbgrader/autograde";4142import { CourseActions } from "./actions";4344export const DEFAULT_LICENSE_UPGRADE_HOST_PROJECT = false;4546export type TerminalCommandOutput = TypedMap<{47project_id: string;48stdout?: string;49stderr?: string;50time_ms?: number;51}>;5253export type TerminalCommand = TypedMap<{54input?: string;55output?: List<TerminalCommandOutput>;56running?: boolean;57}>;5859export type StudentRecord = TypedMap<{60create_project?: number; // Time the student project was created61account_id?: string;62student_id: string;63first_name?: string;64last_name?: string;65last_active?: number;66hosting?: string;67email_address?: string;68project_id?: string;69deleted?: boolean;70// deleted_account: true if the account_id is known to have been deleted71deleted_account?: boolean;72note?: string;73last_email_invite?: number;74}>;7576export type StudentsMap = Map<string, StudentRecord>;7778export type LastCopyInfo = {79time?: number;80error?: string;81start?: number;82};8384export type AssignmentRecord = TypedMap<{85assignment_id: string;86deleted: boolean;87due_date: string; // iso string88path: string;89peer_grade?: {90enabled: boolean;91due_date: number;92map: { [student_id: string]: string[] }; // map from student_id to *who* will grade that student93};94note: string;9596last_assignment?: { [student_id: string]: LastCopyInfo };97last_collect?: { [student_id: string]: LastCopyInfo };98last_peer_assignment?: { [student_id: string]: LastCopyInfo };99last_peer_collect?: { [student_id: string]: LastCopyInfo };100last_return_graded?: { [student_id: string]: LastCopyInfo };101102skip_assignment: boolean;103skip_collect: boolean;104skip_grading: boolean;105target_path: string;106collect_path: string;107graded_path: string;108109nbgrader?: boolean; // if true, probably includes at least one nbgrader ipynb file110listing?: DirectoryListingEntry[];111112grades?: { [student_id: string]: string };113comments?: { [student_id: string]: string };114nbgrader_scores?: {115[student_id: string]: { [ipynb: string]: NotebookScores | string };116};117nbgrader_score_ids?: { [ipynb: string]: string[] };118}>;119120export type AssignmentsMap = Map<string, AssignmentRecord>;121122export type HandoutRecord = TypedMap<{123deleted: boolean;124handout_id: string;125target_path: string;126path: string;127note: string;128status: { [student_id: string]: LastCopyInfo };129}>;130131export type HandoutsMap = Map<string, HandoutRecord>;132133export type SortDescription = TypedMap<{134column_name: string;135is_descending: boolean;136}>;137138export type CourseSettingsRecord = TypedMap<{139allow_collabs: boolean;140student_project_functionality?: StudentProjectFunctionality;141description: string;142email_invite: string;143institute_pay: boolean;144pay: string | Date;145payInfo?: TypedMap<PurchaseInfo>;146payCost?: number;147shared_project_id: string;148student_pay: boolean;149title: string;150upgrade_goal: Map<any, any>;151license_upgrade_host_project?: boolean; // https://github.com/sagemathinc/cocalc/issues/5360152site_license_id?: string;153site_license_removed?: string; // comma separated list of licenses that have been explicitly removed from this course.154site_license_strategy?: SiteLicenseStrategy;155copy_parallel?: number;156nbgrader_grade_in_instructor_project?: boolean; // deprecated157nbgrader_grade_project?: string;158nbgrader_include_hidden_tests?: boolean;159nbgrader_cell_timeout_ms?: number;160nbgrader_timeout_ms?: number;161nbgrader_max_output?: number;162nbgrader_max_output_per_cell?: number;163nbgrader_parallel?: number;164datastore?: Datastore;165envvars?: EnvVarsRecord;166copy_config_targets: CopyConfigurationTargets;167copy_config_options: CopyConfigurationOptions;168}>;169170export const CourseSetting = createTypedMap<CourseSettingsRecord>();171172export type IsGradingMap = Map<string, boolean>;173174export type ActivityMap = Map<number, string>;175176// This NBgraderRunInfo is a map from what nbgrader task is running177// to when it was started (ms since epoch). The keys are as follows:178// 36-character [account_id] = means that entire assignment with that id is being graded179// [account_id]-[student_id] = the particular assignment for that student is being graded180// We do not track grading of individual files in an assignment.181// This is NOT sync'd across users, since that would increase network traffic and182// is probably not critical to do, since the worst case scenario is just running nbgrader183// more than once at the same time, which is probably just *inefficient*.184export type NBgraderRunInfo = Map<string, number>;185186export interface CourseState {187activity: ActivityMap;188action_all_projects_state: string;189active_student_sort: { column_name: string; is_descending: boolean };190active_assignment_sort: { column_name: string; is_descending: boolean };191assignments: AssignmentsMap;192course_filename: string;193course_project_id: string;194configuring_projects?: boolean;195reinviting_students?: boolean;196error?: string;197expanded_students: Set<string>;198expanded_assignments: Set<string>;199expanded_peer_configs: Set<string>;200expanded_handouts: Set<string>;201expanded_skip_gradings: Set<string>;202active_feedback_edits: IsGradingMap;203handouts: HandoutsMap;204loading: boolean; // initially loading the syncdoc from disk.205saving: boolean;206settings: CourseSettingsRecord;207show_save_button: boolean;208students: StudentsMap;209unsaved?: boolean;210terminal_command?: TerminalCommand;211nbgrader_run_info?: NBgraderRunInfo;212// map from student_id to a filter string.213assignmentFilter?: Map<string, string>;214// each page -- students, assignments, handouts (etc.?) has a filter. This is the state of that filter.215pageFilter?: Map<string, string>;216}217218export class CourseStore extends Store<CourseState> {219private assignment_status_cache?: {220[assignment_id: string]: AssignmentStatus;221};222private handout_status_cache?: {223[key: string]: { handout: number; not_handout: number };224};225226// Return true if there are any non-deleted assignments that use peer grading227public any_assignment_uses_peer_grading(): boolean {228for (const [, assignment] of this.get_assignments()) {229if (230assignment.getIn(["peer_grade", "enabled"]) &&231!assignment.get("deleted")232) {233return true;234}235}236return false;237}238239// Return Javascript array of the student_id's of the students240// that graded the given student, or undefined if no relevant assignment.241public get_peers_that_graded_student(242assignment_id: string,243student_id: string,244): string[] {245const peers: string[] = [];246const assignment = this.get_assignment(assignment_id);247if (assignment == null) return peers;248const map = assignment.getIn(["peer_grade", "map"]);249if (map == null) {250return peers;251}252for (const [other_student_id, who_grading] of map) {253if (who_grading.includes(student_id)) {254peers.push(other_student_id as string); // typescript thinks it could be a number?255}256}257return peers;258}259260public get_shared_project_id(): string | undefined {261// return project_id (a string) if shared project has been created,262// or undefined or empty string otherwise.263return this.getIn(["settings", "shared_project_id"]);264}265266public get_pay(): string | Date {267const settings = this.get("settings");268if (settings == null || !settings.get("student_pay")) return "";269const pay = settings.get("pay");270if (!pay) return "";271return pay;272}273274public get_payInfo(): PurchaseInfo | null {275const settings = this.get("settings");276if (settings == null || !settings.get("student_pay")) return null;277const payInfo = settings.get("payInfo")?.toJS();278if (!payInfo) return null;279return payInfo;280}281282public get_datastore(): Datastore {283const settings = this.get("settings");284if (settings == null || settings.get("datastore") == null) return undefined;285const ds = settings.get("datastore");286if (typeof ds === "boolean" || Array.isArray(ds)) {287return ds;288} else {289console.warn(`course/get_datastore: encountered faulty value:`, ds);290return undefined;291}292}293294public get_envvars(): EnvVars | undefined {295const envvars: unknown = this.getIn(["settings", "envvars"]);296if (envvars == null) return undefined;297if (typeof (envvars as any)?.toJS === "function") {298return (envvars as any).toJS();299} else {300console.warn(`course/get_envvars: encountered faulty value:`, envvars);301return;302}303}304305public get_allow_collabs(): boolean {306return !!this.getIn(["settings", "allow_collabs"]);307}308309public get_email_invite(): string {310const invite = this.getIn(["settings", "email_invite"]);311if (invite) return invite;312const site_name = redux.getStore("customize").get("site_name") ?? SITE_NAME;313return `Hello!\n\nWe will use ${site_name} for the course *{title}*.\n\nPlease sign up!\n\n--\n\n{name}`;314}315316public get_students(): StudentsMap {317return this.get("students");318}319320// Return the student's name as a string, using a321// bunch of heuristics to try to present the best322// reasonable name, given what we know. For example,323// it uses an instructor-given custom name if it was set.324public get_student_name(student_id: string): string {325const { student } = this.resolve({ student_id });326if (student == null) {327// Student does not exist at all in store -- this shouldn't happen328return "Unknown Student";329}330// Try instructor assigned name:331if (student.get("first_name")?.trim() || student.get("last_name")?.trim()) {332return [333student.get("first_name", "")?.trim(),334student.get("last_name", "")?.trim(),335].join(" ");336}337const account_id = student.get("account_id");338if (account_id == null) {339// Student doesn't have an account yet on CoCalc (that we know about).340// Email address:341if (student.has("email_address")) {342return student.get("email_address")!;343}344// One of the above had to work, since we add students by email or account.345// But put this in anyways:346return "Unknown Student";347}348// Now we have a student with a known CoCalc account.349// We would have returned early above if there was an instructor assigned name,350// so we just return their name from cocalc, if known.351const users = this.redux.getStore("users");352if (users == null) throw Error("users must be defined");353const name = users.get_name(account_id);354if (name?.trim()) return name;355// This situation usually shouldn't happen, but maybe could in case the user was known but356// then removed themselves as a collaborator, or something else odd.357if (student.has("email_address")) {358return student.get("email_address")!;359}360// OK, now there is really no way to identify this student. I suppose this could361// happen if the student was added by searching for their name, then they removed362// themselves. Nothing useful we can do at this point.363return "Unknown Student";364}365366// Returns student name as with get_student_name above,367// but also include an email address in angle braces,368// if one is known in a full version of the name.369// This is purely meant to provide a bit of extra info370// for the instructor, and not actually used to send emails.371public get_student_name_extra(student_id: string): {372simple: string;373full: string;374} {375const { student } = this.resolve({ student_id });376if (student == null) {377return { simple: "Unknown", full: "Unknown Student" };378}379const email = student.get("email_address");380const simple = this.get_student_name(student_id);381let extra: string = "";382if (383(student.has("first_name") || student.has("last_name")) &&384student.has("account_id")385) {386const users = this.redux.getStore("users");387if (users != null) {388const name = users.get_name(student.get("account_id"));389if (name != null) {390extra = ` (You call them "${student.has("first_name")} ${student.has(391"last_name",392)}", but they call themselves "${name}".)`;393}394}395}396return { simple, full: email ? `${simple} <${email}>${extra}` : simple };397}398399// Return a name that should sort in a sensible way in400// alphabetical order. This is mainly used for CSV export,401// and is not something that will ever get looked at.402public get_student_sort_name(student_id: string): string {403const { student } = this.resolve({ student_id });404if (student == null) {405return student_id; // keeps the sort stable406}407if (student.has("first_name") || student.has("last_name")) {408return [student.get("last_name", ""), student.get("first_name", "")].join(409" ",410);411}412const account_id = student.get("account_id");413if (account_id == null) {414if (student.has("email_address")) {415return student.get("email_address")!;416}417return student_id;418}419const users = this.redux.getStore("users");420if (users == null) return student_id;421return [422users.get_last_name(account_id),423users.get_first_name(account_id),424].join(" ");425}426427public get_student_email(student_id: string): string {428return this.getIn(["students", student_id, "email_address"], "");429}430431public get_student_ids(opts: { deleted?: boolean } = {}): string[] {432const v: string[] = [];433opts.deleted = !!opts.deleted;434for (const [student_id, val] of this.get("students")) {435if (!!val.get("deleted") == opts.deleted) {436v.push(student_id);437}438}439return v;440}441442// return list of all student projects443public get_student_project_ids(444opts: {445include_deleted?: boolean;446deleted_only?: boolean;447} = {},448): string[] {449// include_deleted = if true, also include deleted projects450// deleted_only = if true, only include deleted projects451const { include_deleted, deleted_only } = opts;452453let v: string[] = [];454455for (const [, val] of this.get("students")) {456const project_id = val.get("project_id");457if (!project_id) {458continue;459}460if (deleted_only) {461if (include_deleted && val.get("deleted")) {462v.push(project_id);463}464} else if (include_deleted) {465v.push(project_id);466} else if (!val.get("deleted")) {467v.push(project_id);468}469}470return v;471}472473public get_student(student_id: string): StudentRecord | undefined {474// return student with given id475return this.getIn(["students", student_id]);476}477478public get_student_project_id(student_id: string): string | undefined {479return this.getIn(["students", student_id, "project_id"]);480}481482// Return a Javascript array of immutable.js StudentRecord maps, sorted483// by sort name (so first last name).484public get_sorted_students(): StudentRecord[] {485const v: StudentRecord[] = [];486for (const [, student] of this.get("students")) {487if (!student.get("deleted")) {488v.push(student);489}490}491v.sort((a, b) =>492cmp(493this.get_student_sort_name(a.get("student_id")),494this.get_student_sort_name(b.get("student_id")),495),496);497return v;498}499500public get_grade(assignment_id: string, student_id: string): string {501const { assignment } = this.resolve({ assignment_id });502if (assignment == null) return "";503const r = assignment.getIn(["grades", student_id], "");504return r == null ? "" : r;505}506507public get_nbgrader_scores(508assignment_id: string,509student_id: string,510): { [ipynb: string]: NotebookScores | string } | undefined {511const { assignment } = this.resolve({ assignment_id });512return assignment?.getIn(["nbgrader_scores", student_id])?.toJS();513}514515public get_nbgrader_score_ids(516assignment_id: string,517): { [ipynb: string]: string[] } | undefined {518const { assignment } = this.resolve({ assignment_id });519const ids = assignment?.get("nbgrader_score_ids")?.toJS();520if (ids != null) return ids;521// TODO: If the score id's aren't known, it would be nice to try522// to parse the master ipynb file and compute them. We still523// allow for the possibility that this fails and return undefined524// in that case. This is painful since it involves async calls525// to the backend, and the code that does this as part of grading526// is deep inside other functions. The list we return here527// is always assumed to be used on a "best effort" basis, so this528// is at worst annoying.529}530531public get_comments(assignment_id: string, student_id: string): string {532const { assignment } = this.resolve({ assignment_id });533if (assignment == null) return "";534const r = assignment.getIn(["comments", student_id], "");535return r == null ? "" : r;536}537538public get_due_date(assignment_id: string): Date | undefined {539const { assignment } = this.resolve({ assignment_id });540if (assignment == null) return;541const due_date = assignment.get("due_date");542if (due_date != null) {543return new Date(due_date);544}545}546547public get_assignments(): AssignmentsMap {548return this.get("assignments");549}550551public get_sorted_assignments(): AssignmentRecord[] {552const v: AssignmentRecord[] = [];553for (const [, assignment] of this.get_assignments()) {554if (!assignment.get("deleted")) {555v.push(assignment);556}557}558const f = function (a: AssignmentRecord) {559return [a.get("due_date", 0), a.get("path", "")];560};561v.sort((a, b) => cmp_array(f(a), f(b)));562return v;563}564565// return assignment with given id if a string; otherwise, just return566// the latest version of the assignment as stored in the store.567public get_assignment(assignment_id: string): AssignmentRecord | undefined {568return this.getIn(["assignments", assignment_id]);569}570571// if deleted is true return only deleted assignments572public get_assignment_ids(opts: { deleted?: boolean } = {}): string[] {573const v: string[] = [];574for (const [assignment_id, val] of this.get_assignments()) {575if (!!val.get("deleted") == opts.deleted) {576v.push(assignment_id);577}578}579return v;580}581582private num_nondeleted(a): number {583let n: number = 0;584for (const [, x] of a) {585if (!x.get("deleted")) {586n += 1;587}588}589return n;590}591592// number of non-deleted students593public num_students(): number {594return this.num_nondeleted(this.get_students());595}596597// number of student projects that are currently running598public num_running_projects(project_map): number {599let n = 0;600for (const [, student] of this.get_students()) {601if (!student.get("deleted")) {602if (603project_map.getIn([student.get("project_id"), "state", "state"]) ==604"running"605) {606n += 1;607}608}609}610return n;611}612613// number of non-deleted assignments614public num_assignments(): number {615return this.num_nondeleted(this.get_assignments());616}617618// number of non-deleted handouts619public num_handouts(): number {620return this.num_nondeleted(this.get_handouts());621}622623// get info about relation between a student and a given assignment624public student_assignment_info(625student_id: string,626assignment_id: string,627): {628last_assignment?: LastCopyInfo;629last_collect?: LastCopyInfo;630last_peer_assignment?: LastCopyInfo;631last_peer_collect?: LastCopyInfo;632last_return_graded?: LastCopyInfo;633student_id: string;634assignment_id: string;635peer_assignment: boolean;636peer_collect: boolean;637} {638const { assignment } = this.resolve({ assignment_id });639if (assignment == null) {640return {641student_id,642assignment_id,643peer_assignment: false,644peer_collect: false,645};646}647648const status = this.get_assignment_status(assignment_id);649if (status == null) throw Error("bug"); // can't happen650651// Important to return undefined if no info -- assumed in code652function get_info(field: string): undefined | LastCopyInfo {653if (assignment == null) throw Error("bug"); // can't happen654const x = assignment.getIn([field, student_id]);655if (x == null) return;656return (x as any).toJS();657}658659const peer_assignment =660status.not_collect + status.not_assignment == 0 && status.collect != 0;661const peer_collect =662status.not_peer_assignment != null && status.not_peer_assignment == 0;663664return {665last_assignment: get_info("last_assignment"),666last_collect: get_info("last_collect"),667last_peer_assignment: get_info("last_peer_assignment"),668last_peer_collect: get_info("last_peer_collect"),669last_return_graded: get_info("last_return_graded"),670student_id,671assignment_id,672peer_assignment,673peer_collect,674};675}676677// Return true if the assignment was copied to/from the678// student, in the given step of the workflow.679// Even an attempt to copy with an error counts,680// unless no_error is true, in which case it doesn't.681public last_copied(682step: AssignmentCopyStep,683assignment_id: string,684student_id: string,685no_error?: boolean,686): boolean {687const x = this.getIn([688"assignments",689assignment_id,690`last_${step}`,691student_id,692]);693if (x == null) {694return false;695}696const y: TypedMap<LastCopyInfo> = x;697if (no_error && y.get("error")) {698return false;699}700return y.get("time") != null;701}702703public has_grade(assignment_id: string, student_id: string): boolean {704return !!this.getIn(["assignments", assignment_id, "grades", student_id]);705}706707public get_assignment_status(708assignment_id: string,709): AssignmentStatus | undefined {710//711// Compute and return an object that has fields (deleted students are ignored)712//713// assignment - number of students who have received assignment includes714// all students if skip_assignment is true715// not_assignment - number of students who have NOT received assignment716// always 0 if skip_assignment is true717// collect - number of students from whom we have collected assignment includes718// all students if skip_collect is true719// not_collect - number of students from whom we have NOT collected assignment but we sent it to them720// always 0 if skip_assignment is true721// peer_assignment - number of students who have received peer assignment722// (only present if peer grading enabled; similar for peer below)723// not_peer_assignment - number of students who have NOT received peer assignment724// peer_collect - number of students from whom we have collected peer grading725// not_peer_collect - number of students from whome we have NOT collected peer grading726// return_graded - number of students to whom we've returned assignment727// not_return_graded - number of students to whom we've NOT returned assignment728// but we collected it from them *and* either assigned a grade or skip grading729//730// This function caches its result and only recomputes values when the store changes,731// so it should be safe to call in render.732//733if (this.assignment_status_cache == null) {734this.assignment_status_cache = {};735this.on("change", () => {736// clear cache on any change to the store737this.assignment_status_cache = {};738});739}740const { assignment } = this.resolve({ assignment_id });741if (assignment == null) {742return;743}744745if (this.assignment_status_cache[assignment_id] != null) {746// we have cached info747return this.assignment_status_cache[assignment_id];748}749750const students: string[] = this.get_student_ids({ deleted: false });751752// Is peer grading enabled?753const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);754const skip_grading: boolean = assignment.get("skip_grading", false);755756const obj: any = {};757for (const t of STEPS(peer)) {758obj[t] = 0;759obj[`not_${t}`] = 0;760}761const info: AssignmentStatus = obj;762for (const student_id of students) {763let previous: boolean = true;764for (const t of STEPS(peer)) {765const x = assignment.getIn([`last_${t}`, student_id]) as766| undefined767| TypedMap<LastCopyInfo>;768if (769(x != null && !x.get("error") && !x.get("start")) ||770assignment.get(`skip_${t}`)771) {772previous = true;773info[t] += 1;774} else {775// add 1 only if the previous step *was* done (and in776// the case of returning, they have a grade)777const graded =778this.has_grade(assignment_id, student_id) || skip_grading;779if ((previous && t !== "return_graded") || graded) {780info[`not_${t}`] += 1;781}782previous = false;783}784}785}786787this.assignment_status_cache[assignment_id] = info;788return info;789}790791public get_handouts(): HandoutsMap {792return this.get("handouts");793}794795public get_handout(handout_id: string): HandoutRecord | undefined {796return this.getIn(["handouts", handout_id]);797}798799public get_handout_ids(opts: { deleted?: boolean } = {}): string[] {800const v: string[] = [];801for (const [handout_id, val] of this.get_handouts()) {802if (!!val.get("deleted") == opts.deleted) {803v.push(handout_id);804}805}806return v;807}808809public student_handout_info(810student_id: string,811handout_id: string,812): { status?: LastCopyInfo; handout_id: string; student_id: string } {813// status -- important to be undefined if no info -- assumed in code814const status = this.getIn(["handouts", handout_id, "status", student_id]);815return {816status: status != null ? status.toJS() : undefined,817student_id,818handout_id,819};820}821822// Return the last time the handout was copied to/from the823// student (in the given step of the workflow), or undefined.824// Even an attempt to copy with an error counts.825public handout_last_copied(handout_id: string, student_id: string): boolean {826const x = this.getIn(["handouts", handout_id, "status", student_id]) as827| TypedMap<LastCopyInfo>828| undefined;829if (x == null) {830return false;831}832if (x.get("error")) {833return false;834}835return x.get("time") != null;836}837838public get_handout_status(839handout_id: string,840): undefined | { handout: number; not_handout: number } {841//842// Compute and return an object that has fields (deleted students are ignored)843//844// handout - number of students who have received handout845// not_handout - number of students who have NOT received handout846// This function caches its result and only recomputes values when the store changes,847// so it should be safe to call in render.848//849if (this.handout_status_cache == null) {850this.handout_status_cache = {};851this.on("change", () => {852// clear cache on any change to the store853this.handout_status_cache = {};854});855}856const { handout } = this.resolve({ handout_id });857if (handout == null) {858return undefined;859}860861if (this.handout_status_cache[handout_id] != null) {862return this.handout_status_cache[handout_id];863}864865const students: string[] = this.get_student_ids({ deleted: false });866867const info = {868handout: 0,869not_handout: 0,870};871872const status = handout.get("status");873for (const student_id of students) {874if (status == null) {875info.not_handout += 1;876} else {877const x = status.get(student_id);878if (x != null && !x.get("error")) {879info.handout += 1;880} else {881info.not_handout += 1;882}883}884}885886this.handout_status_cache[handout_id] = info;887return info;888}889890public get_upgrade_plan(upgrade_goal: UpgradeGoal) {891const account_store: any = this.redux.getStore("account");892const project_map = this.redux.getStore("projects").get("project_map");893if (project_map == null) throw Error("not fully loaded");894const plan = project_upgrades.upgrade_plan({895account_id: account_store.get_account_id(),896purchased_upgrades: account_store.get_total_upgrades(),897project_map,898student_project_ids: set(899this.get_student_project_ids({900include_deleted: true,901}),902),903deleted_project_ids: set(904this.get_student_project_ids({905include_deleted: true,906deleted_only: true,907}),908),909upgrade_goal,910});911return plan;912}913914private resolve(opts: {915assignment_id?: string;916student_id?: string;917handout_id?: string;918}): {919student?: StudentRecord;920assignment?: AssignmentRecord;921handout?: HandoutRecord;922} {923const actions = this.redux.getActions(this.name);924if (actions == null) return {};925const x = (actions as CourseActions).resolve(opts);926delete (x as any).store;927return x;928}929930// List of ids of (non-deleted) assignments that have been931// assigned to at least one student.932public get_assigned_assignment_ids(): string[] {933const v: string[] = [];934for (const [assignment_id, val] of this.get_assignments()) {935if (val.get("deleted")) continue;936const x = val.get(`last_assignment`);937if (x != null && x.size > 0) {938v.push(assignment_id);939}940}941return v;942}943944// List of ids of (non-deleted) handouts that have been copied945// out to at least one student.946public get_assigned_handout_ids(): string[] {947const v: string[] = [];948for (const [handout_id, val] of this.get_handouts()) {949if (val.get("deleted")) continue;950const x = val.get(`status`);951if (x != null && x.size > 0) {952v.push(handout_id);953}954}955return v;956}957958public get_copy_parallel(): number {959const n = this.getIn(["settings", "copy_parallel"]) ?? PARALLEL_DEFAULT;960if (n < 1) return 1;961if (n > MAX_COPY_PARALLEL) return MAX_COPY_PARALLEL;962return n;963}964965public get_nbgrader_parallel(): number {966const n = this.getIn(["settings", "nbgrader_parallel"]) ?? PARALLEL_DEFAULT;967if (n < 1) return 1;968if (n > 50) return 50;969return n;970}971972public async getLicenses(force?: boolean): Promise<{973[license_id: string]: { expired: boolean; runLimit: number };974}> {975const licenses: {976[license_id: string]: { expired: boolean; runLimit: number };977} = {};978const license_ids = this.getIn(["settings", "site_license_id"]) ?? "";979for (const license_id of license_ids.split(",")) {980if (!license_id) continue;981try {982const license_info = await site_license_public_info(license_id, force);983if (license_info == null) continue;984const { expires, run_limit } = license_info;985const expired = !!(expires && expires <= new Date());986const runLimit = run_limit ? run_limit : 999999999999999; // effectively unlimited987licenses[license_id] = { expired, runLimit };988} catch (err) {989console.warn(`Error getting license info for ${license_id}`, err);990}991}992return licenses;993}994}995996export function get_nbgrader_score(scores: {997[ipynb: string]: NotebookScores | string;998}): { score: number; points: number; error?: boolean; manual_needed: boolean } {999let points: number = 0;1000let score: number = 0;1001let error: boolean = false;1002let manual_needed: boolean = false;1003for (const ipynb in scores) {1004const x = scores[ipynb];1005if (typeof x == "string") {1006error = true;1007continue;1008}1009for (const grade_id in x) {1010const y = x[grade_id];1011if (y.score == null && y.manual) {1012manual_needed = true;1013}1014if (y.score) {1015score += y.score;1016}1017points += y.points;1018}1019}1020return { score, points, error, manual_needed };1021}102210231024