Path: blob/master/src/packages/frontend/course/store.ts
1503 views
/*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,38ComputeServerConfig,39} from "./types";4041import { NotebookScores } from "../jupyter/nbgrader/autograde";4243import { CourseActions } from "./actions";4445export const DEFAULT_LICENSE_UPGRADE_HOST_PROJECT = false;4647export type TerminalCommandOutput = TypedMap<{48project_id: string;49stdout?: string;50stderr?: string;51time_ms?: number;52}>;5354export type TerminalCommand = TypedMap<{55input?: string;56output?: List<TerminalCommandOutput>;57running?: boolean;58}>;5960export type StudentRecord = TypedMap<{61create_project?: number; // Time the student project was created62account_id?: string;63student_id: string;64first_name?: string;65last_name?: string;66last_active?: number;67hosting?: string;68email_address?: string;69project_id?: string;70deleted?: boolean;71// deleted_account: true if the account_id is known to have been deleted72deleted_account?: boolean;73note?: string;74last_email_invite?: number;75}>;7677export type StudentsMap = Map<string, StudentRecord>;7879export type LastCopyInfo = {80time?: number;81error?: string;82start?: number;83};8485export type AssignmentRecord = TypedMap<{86assignment_id: string;87deleted: boolean;88due_date: string; // iso string89path: string;90peer_grade?: {91enabled: boolean;92due_date: number;93map: { [student_id: string]: string[] }; // map from student_id to *who* will grade that student94};95note: string;9697last_assignment?: { [student_id: string]: LastCopyInfo };98last_collect?: { [student_id: string]: LastCopyInfo };99last_peer_assignment?: { [student_id: string]: LastCopyInfo };100last_peer_collect?: { [student_id: string]: LastCopyInfo };101last_return_graded?: { [student_id: string]: LastCopyInfo };102103skip_assignment: boolean;104skip_collect: boolean;105skip_grading: boolean;106target_path: string;107collect_path: string;108graded_path: string;109110nbgrader?: boolean; // if true, probably includes at least one nbgrader ipynb file111listing?: DirectoryListingEntry[];112113grades?: { [student_id: string]: string };114comments?: { [student_id: string]: string };115nbgrader_scores?: {116[student_id: string]: { [ipynb: string]: NotebookScores | string };117};118nbgrader_score_ids?: { [ipynb: string]: string[] };119compute_server?: ComputeServerConfig;120}>;121122export type AssignmentsMap = Map<string, AssignmentRecord>;123124export type HandoutRecord = TypedMap<{125deleted: boolean;126handout_id: string;127target_path: string;128path: string;129note: string;130status: { [student_id: string]: LastCopyInfo };131compute_server?: ComputeServerConfig;132}>;133134export type HandoutsMap = Map<string, HandoutRecord>;135136// unit = record or assignment...137export type Unit = TypedMap<{138compute_server?: ComputeServerConfig;139assignment_id?: string;140handout_id?: string;141}>;142143export type SortDescription = TypedMap<{144column_name: string;145is_descending: boolean;146}>;147148export type CourseSettingsRecord = TypedMap<{149allow_collabs: boolean;150student_project_functionality?: StudentProjectFunctionality;151description: string;152email_invite: string;153institute_pay: boolean;154pay: string | Date;155payInfo?: TypedMap<PurchaseInfo>;156payCost?: number;157shared_project_id: string;158student_pay: boolean;159title: string;160upgrade_goal: Map<any, any>;161license_upgrade_host_project?: boolean; // https://github.com/sagemathinc/cocalc/issues/5360162site_license_id?: string;163site_license_removed?: string; // comma separated list of licenses that have been explicitly removed from this course.164site_license_strategy?: SiteLicenseStrategy;165copy_parallel?: number;166nbgrader_grade_in_instructor_project?: boolean; // deprecated167nbgrader_grade_project?: string;168nbgrader_include_hidden_tests?: boolean;169nbgrader_cell_timeout_ms?: number;170nbgrader_timeout_ms?: number;171nbgrader_max_output?: number;172nbgrader_max_output_per_cell?: number;173nbgrader_parallel?: number;174datastore?: Datastore;175envvars?: EnvVarsRecord;176copy_config_targets: CopyConfigurationTargets;177copy_config_options: CopyConfigurationOptions;178}>;179180export const CourseSetting = createTypedMap<CourseSettingsRecord>();181182export type IsGradingMap = Map<string, boolean>;183184export type ActivityMap = Map<number, string>;185186// This NBgraderRunInfo is a map from what nbgrader task is running187// to when it was started (ms since epoch). The keys are as follows:188// 36-character [account_id] = means that entire assignment with that id is being graded189// [account_id]-[student_id] = the particular assignment for that student is being graded190// We do not track grading of individual files in an assignment.191// This is NOT sync'd across users, since that would increase network traffic and192// is probably not critical to do, since the worst case scenario is just running nbgrader193// more than once at the same time, which is probably just *inefficient*.194export type NBgraderRunInfo = Map<string, number>;195196export interface CourseState {197activity: ActivityMap;198action_all_projects_state: string;199active_student_sort: { column_name: string; is_descending: boolean };200active_assignment_sort: { column_name: string; is_descending: boolean };201assignments: AssignmentsMap;202course_filename: string;203course_project_id: string;204configuring_projects?: boolean;205reinviting_students?: boolean;206error?: string;207expanded_students: Set<string>;208expanded_assignments: Set<string>;209expanded_peer_configs: Set<string>;210expanded_handouts: Set<string>;211expanded_skip_gradings: Set<string>;212active_feedback_edits: IsGradingMap;213handouts: HandoutsMap;214loading: boolean; // initially loading the syncdoc from disk.215saving: boolean;216settings: CourseSettingsRecord;217show_save_button: boolean;218students: StudentsMap;219unsaved?: boolean;220terminal_command?: TerminalCommand;221nbgrader_run_info?: NBgraderRunInfo;222// map from student_id to a filter string.223assignmentFilter?: Map<string, string>;224// each page -- students, assignments, handouts (etc.?) has a filter. This is the state of that filter.225pageFilter?: Map<string, string>;226}227228export class CourseStore extends Store<CourseState> {229private assignment_status_cache?: {230[assignment_id: string]: AssignmentStatus;231};232private handout_status_cache?: {233[key: string]: { handout: number; not_handout: number };234};235236// Return true if there are any non-deleted assignments that use peer grading237public any_assignment_uses_peer_grading(): boolean {238for (const [, assignment] of this.get_assignments()) {239if (240assignment.getIn(["peer_grade", "enabled"]) &&241!assignment.get("deleted")242) {243return true;244}245}246return false;247}248249// Return Javascript array of the student_id's of the students250// that graded the given student, or undefined if no relevant assignment.251public get_peers_that_graded_student(252assignment_id: string,253student_id: string,254): string[] {255const peers: string[] = [];256const assignment = this.get_assignment(assignment_id);257if (assignment == null) return peers;258const map = assignment.getIn(["peer_grade", "map"]);259if (map == null) {260return peers;261}262for (const [other_student_id, who_grading] of map) {263if (who_grading.includes(student_id)) {264peers.push(other_student_id as string); // typescript thinks it could be a number?265}266}267return peers;268}269270public get_shared_project_id(): string | undefined {271// return project_id (a string) if shared project has been created,272// or undefined or empty string otherwise.273return this.getIn(["settings", "shared_project_id"]);274}275276public get_pay(): string | Date {277const settings = this.get("settings");278if (settings == null || !settings.get("student_pay")) return "";279const pay = settings.get("pay");280if (!pay) return "";281return pay;282}283284public get_payInfo(): PurchaseInfo | null {285const settings = this.get("settings");286if (settings == null || !settings.get("student_pay")) return null;287const payInfo = settings.get("payInfo")?.toJS();288if (!payInfo) return null;289return payInfo;290}291292public get_datastore(): Datastore {293const settings = this.get("settings");294if (settings == null || settings.get("datastore") == null) return undefined;295const ds = settings.get("datastore");296if (typeof ds === "boolean" || Array.isArray(ds)) {297return ds;298} else {299console.warn(`course/get_datastore: encountered faulty value:`, ds);300return undefined;301}302}303304public get_envvars(): EnvVars | undefined {305const envvars: unknown = this.getIn(["settings", "envvars"]);306if (envvars == null) return undefined;307if (typeof (envvars as any)?.toJS === "function") {308return (envvars as any).toJS();309} else {310console.warn(`course/get_envvars: encountered faulty value:`, envvars);311return;312}313}314315public get_allow_collabs(): boolean {316return !!this.getIn(["settings", "allow_collabs"]);317}318319public get_email_invite(): string {320const invite = this.getIn(["settings", "email_invite"]);321if (invite) return invite;322const site_name = redux.getStore("customize").get("site_name") ?? SITE_NAME;323return `Hello!\n\nWe will use ${site_name} for the course *{title}*.\n\nPlease sign up!\n\n--\n\n{name}`;324}325326public get_students(): StudentsMap {327return this.get("students");328}329330// Return the student's name as a string, using a331// bunch of heuristics to try to present the best332// reasonable name, given what we know. For example,333// it uses an instructor-given custom name if it was set.334public get_student_name(student_id: string): string {335const { student } = this.resolve({ student_id });336if (student == null) {337// Student does not exist at all in store -- this shouldn't happen338return "Unknown Student";339}340// Try instructor assigned name:341if (student.get("first_name")?.trim() || student.get("last_name")?.trim()) {342return [343student.get("first_name", "")?.trim(),344student.get("last_name", "")?.trim(),345].join(" ");346}347const account_id = student.get("account_id");348if (account_id == null) {349// Student doesn't have an account yet on CoCalc (that we know about).350// Email address:351if (student.has("email_address")) {352return student.get("email_address")!;353}354// One of the above had to work, since we add students by email or account.355// But put this in anyways:356return "Unknown Student";357}358// Now we have a student with a known CoCalc account.359// We would have returned early above if there was an instructor assigned name,360// so we just return their name from cocalc, if known.361const users = this.redux.getStore("users");362if (users == null) throw Error("users must be defined");363const name = users.get_name(account_id);364if (name?.trim()) return name;365// This situation usually shouldn't happen, but maybe could in case the user was known but366// then removed themselves as a collaborator, or something else odd.367if (student.has("email_address")) {368return student.get("email_address")!;369}370// OK, now there is really no way to identify this student. I suppose this could371// happen if the student was added by searching for their name, then they removed372// themselves. Nothing useful we can do at this point.373return "Unknown Student";374}375376// Returns student name as with get_student_name above,377// but also include an email address in angle braces,378// if one is known in a full version of the name.379// This is purely meant to provide a bit of extra info380// for the instructor, and not actually used to send emails.381public get_student_name_extra(student_id: string): {382simple: string;383full: string;384} {385const { student } = this.resolve({ student_id });386if (student == null) {387return { simple: "Unknown", full: "Unknown Student" };388}389const email = student.get("email_address");390const simple = this.get_student_name(student_id);391let extra: string = "";392if (393(student.has("first_name") || student.has("last_name")) &&394student.has("account_id")395) {396const users = this.redux.getStore("users");397if (users != null) {398const name = users.get_name(student.get("account_id"));399if (name != null) {400extra = ` (You call them "${student.get("first_name")} ${student.get(401"last_name",402)}", but they call themselves "${name}".)`;403}404}405}406return { simple, full: email ? `${simple} <${email}>${extra}` : simple };407}408409// Return a name that should sort in a sensible way in410// alphabetical order. This is mainly used for CSV export,411// and is not something that will ever get looked at.412public get_student_sort_name(student_id: string): string {413const { student } = this.resolve({ student_id });414if (student == null) {415return student_id; // keeps the sort stable416}417if (student.has("first_name") || student.has("last_name")) {418return [student.get("last_name", ""), student.get("first_name", "")].join(419" ",420);421}422const account_id = student.get("account_id");423if (account_id == null) {424if (student.has("email_address")) {425return student.get("email_address")!;426}427return student_id;428}429const users = this.redux.getStore("users");430if (users == null) return student_id;431return [432users.get_last_name(account_id),433users.get_first_name(account_id),434].join(" ");435}436437public get_student_email(student_id: string): string {438return this.getIn(["students", student_id, "email_address"], "");439}440441public get_student_ids(opts: { deleted?: boolean } = {}): string[] {442const v: string[] = [];443opts.deleted = !!opts.deleted;444for (const [student_id, val] of this.get("students")) {445if (!!val.get("deleted") == opts.deleted) {446v.push(student_id);447}448}449return v;450}451452// return list of all student projects453public get_student_project_ids(454opts: {455include_deleted?: boolean;456deleted_only?: boolean;457} = {},458): string[] {459// include_deleted = if true, also include deleted projects460// deleted_only = if true, only include deleted projects461const { include_deleted, deleted_only } = opts;462463let v: string[] = [];464465for (const [, val] of this.get("students")) {466const project_id = val.get("project_id");467if (!project_id) {468continue;469}470if (deleted_only) {471if (include_deleted && val.get("deleted")) {472v.push(project_id);473}474} else if (include_deleted) {475v.push(project_id);476} else if (!val.get("deleted")) {477v.push(project_id);478}479}480return v;481}482483public get_student(student_id: string): StudentRecord | undefined {484// return student with given id485return this.getIn(["students", student_id]);486}487488public get_student_project_id(student_id: string): string | undefined {489return this.getIn(["students", student_id, "project_id"]);490}491492// Return a Javascript array of immutable.js StudentRecord maps, sorted493// by sort name (so first last name).494public get_sorted_students(): StudentRecord[] {495const v: StudentRecord[] = [];496for (const [, student] of this.get("students")) {497if (!student.get("deleted")) {498v.push(student);499}500}501v.sort((a, b) =>502cmp(503this.get_student_sort_name(a.get("student_id")),504this.get_student_sort_name(b.get("student_id")),505),506);507return v;508}509510public get_grade(assignment_id: string, student_id: string): string {511const { assignment } = this.resolve({ assignment_id });512if (assignment == null) return "";513const r = assignment.getIn(["grades", student_id], "");514return r == null ? "" : r;515}516517public get_nbgrader_scores(518assignment_id: string,519student_id: string,520): { [ipynb: string]: NotebookScores | string } | undefined {521const { assignment } = this.resolve({ assignment_id });522return assignment?.getIn(["nbgrader_scores", student_id])?.toJS();523}524525public get_nbgrader_score_ids(526assignment_id: string,527): { [ipynb: string]: string[] } | undefined {528const { assignment } = this.resolve({ assignment_id });529const ids = assignment?.get("nbgrader_score_ids")?.toJS();530if (ids != null) return ids;531// TODO: If the score id's aren't known, it would be nice to try532// to parse the master ipynb file and compute them. We still533// allow for the possibility that this fails and return undefined534// in that case. This is painful since it involves async calls535// to the backend, and the code that does this as part of grading536// is deep inside other functions. The list we return here537// is always assumed to be used on a "best effort" basis, so this538// is at worst annoying.539}540541public get_comments(assignment_id: string, student_id: string): string {542const { assignment } = this.resolve({ assignment_id });543if (assignment == null) return "";544const r = assignment.getIn(["comments", student_id], "");545return r == null ? "" : r;546}547548public get_due_date(assignment_id: string): Date | undefined {549const { assignment } = this.resolve({ assignment_id });550if (assignment == null) return;551const due_date = assignment.get("due_date");552if (due_date != null) {553return new Date(due_date);554}555}556557public get_assignments(): AssignmentsMap {558return this.get("assignments");559}560561public get_sorted_assignments(): AssignmentRecord[] {562const v: AssignmentRecord[] = [];563for (const [, assignment] of this.get_assignments()) {564if (!assignment.get("deleted")) {565v.push(assignment);566}567}568const f = function (a: AssignmentRecord) {569return [a.get("due_date", 0), a.get("path", "")];570};571v.sort((a, b) => cmp_array(f(a), f(b)));572return v;573}574575// return assignment with given id if a string; otherwise, just return576// the latest version of the assignment as stored in the store.577public get_assignment(assignment_id: string): AssignmentRecord | undefined {578return this.getIn(["assignments", assignment_id]);579}580581public get_assignment_ids({582deleted = false,583}: {584// if deleted is true return only deleted assignments585deleted?: boolean;586} = {}): string[] {587const v: string[] = [];588for (const [assignment_id, val] of this.get_assignments()) {589if (!!val.get("deleted") == deleted) {590v.push(assignment_id);591}592}593return v;594}595596private num_nondeleted(a): number {597let n: number = 0;598for (const [, x] of a) {599if (!x.get("deleted")) {600n += 1;601}602}603return n;604}605606// number of non-deleted students607public num_students(): number {608return this.num_nondeleted(this.get_students());609}610611// number of student projects that are currently running612public num_running_projects(project_map): number {613let n = 0;614for (const [, student] of this.get_students()) {615if (!student.get("deleted")) {616if (617project_map.getIn([student.get("project_id"), "state", "state"]) ==618"running"619) {620n += 1;621}622}623}624return n;625}626627// number of non-deleted assignments628public num_assignments(): number {629return this.num_nondeleted(this.get_assignments());630}631632// number of non-deleted handouts633public num_handouts(): number {634return this.num_nondeleted(this.get_handouts());635}636637// get info about relation between a student and a given assignment638public student_assignment_info(639student_id: string,640assignment_id: string,641): {642last_assignment?: LastCopyInfo;643last_collect?: LastCopyInfo;644last_peer_assignment?: LastCopyInfo;645last_peer_collect?: LastCopyInfo;646last_return_graded?: LastCopyInfo;647student_id: string;648assignment_id: string;649peer_assignment: boolean;650peer_collect: boolean;651} {652const { assignment } = this.resolve({ assignment_id });653if (assignment == null) {654return {655student_id,656assignment_id,657peer_assignment: false,658peer_collect: false,659};660}661662const status = this.get_assignment_status(assignment_id);663if (status == null) throw Error("bug"); // can't happen664665// Important to return undefined if no info -- assumed in code666function get_info(field: string): undefined | LastCopyInfo {667if (assignment == null) throw Error("bug"); // can't happen668const x = assignment.getIn([field, student_id]);669if (x == null) return;670return (x as any).toJS();671}672673const peer_assignment =674status.not_collect + status.not_assignment == 0 && status.collect != 0;675const peer_collect =676status.not_peer_assignment != null && status.not_peer_assignment == 0;677678return {679last_assignment: get_info("last_assignment"),680last_collect: get_info("last_collect"),681last_peer_assignment: get_info("last_peer_assignment"),682last_peer_collect: get_info("last_peer_collect"),683last_return_graded: get_info("last_return_graded"),684student_id,685assignment_id,686peer_assignment,687peer_collect,688};689}690691// Return true if the assignment was copied to/from the692// student, in the given step of the workflow.693// Even an attempt to copy with an error counts,694// unless no_error is true, in which case it doesn't.695public last_copied(696step: AssignmentCopyStep,697assignment_id: string,698student_id: string,699no_error?: boolean,700): boolean {701const x = this.getIn([702"assignments",703assignment_id,704`last_${step}`,705student_id,706]);707if (x == null) {708return false;709}710const y: TypedMap<LastCopyInfo> = x;711if (no_error && y.get("error")) {712return false;713}714return y.get("time") != null;715}716717public has_grade(assignment_id: string, student_id: string): boolean {718return !!this.getIn(["assignments", assignment_id, "grades", student_id]);719}720721public get_assignment_status(722assignment_id: string,723): AssignmentStatus | undefined {724//725// Compute and return an object that has fields (deleted students are ignored)726//727// assignment - number of students who have received assignment includes728// all students if skip_assignment is true729// not_assignment - number of students who have NOT received assignment730// always 0 if skip_assignment is true731// collect - number of students from whom we have collected assignment includes732// all students if skip_collect is true733// not_collect - number of students from whom we have NOT collected assignment but we sent it to them734// always 0 if skip_assignment is true735// peer_assignment - number of students who have received peer assignment736// (only present if peer grading enabled; similar for peer below)737// not_peer_assignment - number of students who have NOT received peer assignment738// peer_collect - number of students from whom we have collected peer grading739// not_peer_collect - number of students from whom we have NOT collected peer grading740// return_graded - number of students to whom we've returned assignment741// not_return_graded - number of students to whom we've NOT returned assignment742// but we collected it from them *and* either assigned a grade or skip grading743//744// This function caches its result and only recomputes values when the store changes,745// so it should be safe to call in render.746//747if (this.assignment_status_cache == null) {748this.assignment_status_cache = {};749this.on("change", () => {750// clear cache on any change to the store751this.assignment_status_cache = {};752});753}754const { assignment } = this.resolve({ assignment_id });755if (assignment == null) {756return;757}758759if (this.assignment_status_cache[assignment_id] != null) {760// we have cached info761return this.assignment_status_cache[assignment_id];762}763764const students: string[] = this.get_student_ids({ deleted: false });765766// Is peer grading enabled?767const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);768const skip_grading: boolean = assignment.get("skip_grading", false);769770const obj: any = {};771for (const t of STEPS(peer)) {772obj[t] = 0;773obj[`not_${t}`] = 0;774}775const info: AssignmentStatus = obj;776for (const student_id of students) {777let previous: boolean = true;778for (const t of STEPS(peer)) {779const x = assignment.getIn([`last_${t}`, student_id]) as780| undefined781| TypedMap<LastCopyInfo>;782if (783(x != null && !x.get("error") && !x.get("start")) ||784assignment.get(`skip_${t}`)785) {786previous = true;787info[t] += 1;788} else {789// add 1 only if the previous step *was* done (and in790// the case of returning, they have a grade)791const graded =792this.has_grade(assignment_id, student_id) || skip_grading;793if ((previous && t !== "return_graded") || graded) {794info[`not_${t}`] += 1;795}796previous = false;797}798}799}800801this.assignment_status_cache[assignment_id] = info;802return info;803}804805public get_handouts(): HandoutsMap {806return this.get("handouts");807}808809public get_handout(handout_id: string): HandoutRecord | undefined {810return this.getIn(["handouts", handout_id]);811}812813public get_handout_ids({814deleted = false,815}: { deleted?: boolean } = {}): string[] {816const v: string[] = [];817for (const [handout_id, val] of this.get_handouts()) {818if (!!val.get("deleted") == deleted) {819v.push(handout_id);820}821}822return v;823}824825public student_handout_info(826student_id: string,827handout_id: string,828): { status?: LastCopyInfo; handout_id: string; student_id: string } {829// status -- important to be undefined if no info -- assumed in code830const status = this.getIn(["handouts", handout_id, "status", student_id]);831return {832status: status != null ? status.toJS() : undefined,833student_id,834handout_id,835};836}837838// Return the last time the handout was copied to/from the839// student (in the given step of the workflow), or undefined.840// Even an attempt to copy with an error counts.841public handout_last_copied(handout_id: string, student_id: string): boolean {842const x = this.getIn(["handouts", handout_id, "status", student_id]) as843| TypedMap<LastCopyInfo>844| undefined;845if (x == null) {846return false;847}848if (x.get("error")) {849return false;850}851return x.get("time") != null;852}853854public get_handout_status(855handout_id: string,856): undefined | { handout: number; not_handout: number } {857//858// Compute and return an object that has fields (deleted students are ignored)859//860// handout - number of students who have received handout861// not_handout - number of students who have NOT received handout862// This function caches its result and only recomputes values when the store changes,863// so it should be safe to call in render.864//865if (this.handout_status_cache == null) {866this.handout_status_cache = {};867this.on("change", () => {868// clear cache on any change to the store869this.handout_status_cache = {};870});871}872const { handout } = this.resolve({ handout_id });873if (handout == null) {874return undefined;875}876877if (this.handout_status_cache[handout_id] != null) {878return this.handout_status_cache[handout_id];879}880881const students: string[] = this.get_student_ids({ deleted: false });882883const info = {884handout: 0,885not_handout: 0,886};887888const status = handout.get("status");889for (const student_id of students) {890if (status == null) {891info.not_handout += 1;892} else {893const x = status.get(student_id);894if (x != null && !x.get("error")) {895info.handout += 1;896} else {897info.not_handout += 1;898}899}900}901902this.handout_status_cache[handout_id] = info;903return info;904}905906public get_upgrade_plan(upgrade_goal: UpgradeGoal) {907const account_store: any = this.redux.getStore("account");908const project_map = this.redux.getStore("projects").get("project_map");909if (project_map == null) throw Error("not fully loaded");910const plan = project_upgrades.upgrade_plan({911account_id: account_store.get_account_id(),912purchased_upgrades: account_store.get_total_upgrades(),913project_map,914student_project_ids: set(915this.get_student_project_ids({916include_deleted: true,917}),918),919deleted_project_ids: set(920this.get_student_project_ids({921include_deleted: true,922deleted_only: true,923}),924),925upgrade_goal,926});927return plan;928}929930private resolve(opts: {931assignment_id?: string;932student_id?: string;933handout_id?: string;934}): {935student?: StudentRecord;936assignment?: AssignmentRecord;937handout?: HandoutRecord;938} {939const actions = this.redux.getActions(this.name);940if (actions == null) return {};941const x = (actions as CourseActions).resolve(opts);942delete (x as any).store;943return x;944}945946// List of ids of (non-deleted) assignments that have been947// assigned to at least one student.948public get_assigned_assignment_ids(): string[] {949const v: string[] = [];950for (const [assignment_id, val] of this.get_assignments()) {951if (val.get("deleted")) continue;952const x = val.get(`last_assignment`);953if (x != null && x.size > 0) {954v.push(assignment_id);955}956}957return v;958}959960// List of ids of (non-deleted) handouts that have been copied961// out to at least one student.962public get_assigned_handout_ids(): string[] {963const v: string[] = [];964for (const [handout_id, val] of this.get_handouts()) {965if (val.get("deleted")) continue;966const x = val.get(`status`);967if (x != null && x.size > 0) {968v.push(handout_id);969}970}971return v;972}973974public get_copy_parallel(): number {975const n = this.getIn(["settings", "copy_parallel"]) ?? PARALLEL_DEFAULT;976if (n < 1) return 1;977if (n > MAX_COPY_PARALLEL) return MAX_COPY_PARALLEL;978return n;979}980981public get_nbgrader_parallel(): number {982const n = this.getIn(["settings", "nbgrader_parallel"]) ?? PARALLEL_DEFAULT;983if (n < 1) return 1;984if (n > 50) return 50;985return n;986}987988public async getLicenses(force?: boolean): Promise<{989[license_id: string]: { expired: boolean; runLimit: number };990}> {991const licenses: {992[license_id: string]: { expired: boolean; runLimit: number };993} = {};994const license_ids = this.getIn(["settings", "site_license_id"]) ?? "";995for (const license_id of license_ids.split(",")) {996if (!license_id) continue;997try {998const license_info = await site_license_public_info(license_id, force);999if (license_info == null) continue;1000const { expires, run_limit } = license_info;1001const expired = !!(expires && expires <= new Date());1002const runLimit = run_limit ? run_limit : 999999999999999; // effectively unlimited1003licenses[license_id] = { expired, runLimit };1004} catch (err) {1005console.warn(`Error getting license info for ${license_id}`, err);1006}1007}1008return licenses;1009}10101011getUnit = (id: string) => {1012return this.getIn(["assignments", id]) ?? this.getIn(["handouts", id]);1013};1014}10151016export function get_nbgrader_score(scores: {1017[ipynb: string]: NotebookScores | string;1018}): { score: number; points: number; error?: boolean; manual_needed: boolean } {1019let points: number = 0;1020let score: number = 0;1021let error: boolean = false;1022let manual_needed: boolean = false;1023for (const ipynb in scores) {1024const x = scores[ipynb];1025if (typeof x == "string") {1026error = true;1027continue;1028}1029for (const grade_id in x) {1030const y = x[grade_id];1031if (y.score == null && y.manual) {1032manual_needed = true;1033}1034if (y.score) {1035score += y.score;1036}1037points += y.points;1038}1039}1040return { score, points, error, manual_needed };1041}104210431044