Path: blob/master/src/packages/frontend/course/store.ts
5801 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";29import { DEFAULT_PURCHASE_INFO } from "@cocalc/util/licenses/purchase/student-pay";3031export const PARALLEL_DEFAULT = 5;32export const MAX_COPY_PARALLEL = 25;3334import {35AssignmentCopyStep,36AssignmentStatus,37SiteLicenseStrategy,38UpgradeGoal,39ComputeServerConfig,40} from "./types";4142import { NotebookScores } from "../jupyter/nbgrader/autograde";4344import { CourseActions } from "./actions";4546export const DEFAULT_LICENSE_UPGRADE_HOST_PROJECT = false;4748export type TerminalCommandOutput = TypedMap<{49project_id: string;50stdout?: string;51stderr?: string;52time_ms?: number;53}>;5455export type TerminalCommand = TypedMap<{56input?: string;57output?: List<TerminalCommandOutput>;58running?: boolean;59}>;6061export type StudentRecord = TypedMap<{62create_project?: number; // Time the student project was created63account_id?: string;64student_id: string;65first_name?: string;66last_name?: string;67last_active?: number;68hosting?: string;69email_address?: string;70project_id?: string;71deleted?: boolean;72// deleted_account: true if the account_id is known to have been deleted73deleted_account?: boolean;74note?: string;75last_email_invite?: number;76}>;7778export type StudentsMap = Map<string, StudentRecord>;7980export type LastCopyInfo = {81time?: number;82error?: string;83start?: number;84};8586export type AssignmentRecord = TypedMap<{87assignment_id: string;88deleted: boolean;89due_date: string; // iso string90path: string;91peer_grade?: {92enabled: boolean;93due_date: number;94map: { [student_id: string]: string[] }; // map from student_id to *who* will grade that student95};96note: string;9798last_assignment?: { [student_id: string]: LastCopyInfo };99last_collect?: { [student_id: string]: LastCopyInfo };100last_peer_assignment?: { [student_id: string]: LastCopyInfo };101last_peer_collect?: { [student_id: string]: LastCopyInfo };102last_return_graded?: { [student_id: string]: LastCopyInfo };103104skip_assignment: boolean;105skip_collect: boolean;106skip_grading: boolean;107target_path: string;108collect_path: string;109graded_path: string;110111nbgrader?: boolean; // if true, probably includes at least one nbgrader ipynb file112listing?: DirectoryListingEntry[];113114grades?: { [student_id: string]: string };115comments?: { [student_id: string]: string };116nbgrader_scores?: {117[student_id: string]: { [ipynb: string]: NotebookScores | string };118};119nbgrader_score_ids?: { [ipynb: string]: string[] };120compute_server?: ComputeServerConfig;121}>;122123export type AssignmentsMap = Map<string, AssignmentRecord>;124125export type HandoutRecord = TypedMap<{126deleted: boolean;127handout_id: string;128target_path: string;129path: string;130note: string;131status: { [student_id: string]: LastCopyInfo };132compute_server?: ComputeServerConfig;133}>;134135export type HandoutsMap = Map<string, HandoutRecord>;136137// unit = record or assignment...138export type Unit = TypedMap<{139compute_server?: ComputeServerConfig;140assignment_id?: string;141handout_id?: string;142}>;143144export type SortDescription = TypedMap<{145column_name: string;146is_descending: boolean;147}>;148149export type CourseSettingsRecord = TypedMap<{150allow_collabs: boolean;151student_project_functionality?: StudentProjectFunctionality;152description: string;153email_invite: string;154institute_pay: boolean;155pay: string | Date;156payInfo?: TypedMap<PurchaseInfo>;157payCost?: number;158shared_project_id: string;159student_pay: boolean;160title: string;161upgrade_goal: Map<any, any>;162license_upgrade_host_project?: boolean; // https://github.com/sagemathinc/cocalc/issues/5360163site_license_id?: string;164site_license_removed?: string; // comma separated list of licenses that have been explicitly removed from this course.165site_license_strategy?: SiteLicenseStrategy;166copy_parallel?: number;167nbgrader_grade_in_instructor_project?: boolean; // deprecated168nbgrader_grade_project?: string;169nbgrader_include_hidden_tests?: boolean;170nbgrader_cell_timeout_ms?: number;171nbgrader_timeout_ms?: number;172nbgrader_max_output?: number;173nbgrader_max_output_per_cell?: number;174nbgrader_parallel?: number;175datastore?: Datastore;176envvars?: EnvVarsRecord;177copy_config_targets: CopyConfigurationTargets;178copy_config_options: CopyConfigurationOptions;179}>;180181export const CourseSetting = createTypedMap<CourseSettingsRecord>();182183export type IsGradingMap = Map<string, boolean>;184185export type ActivityMap = Map<number, string>;186187// This NBgraderRunInfo is a map from what nbgrader task is running188// 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 graded190// [account_id]-[student_id] = the particular assignment for that student is being graded191// 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 and193// is probably not critical to do, since the worst case scenario is just running nbgrader194// more than once at the same time, which is probably just *inefficient*.195export type NBgraderRunInfo = Map<string, number>;196197export interface CourseState {198activity: ActivityMap;199action_all_projects_state: string;200active_student_sort: { column_name: string; is_descending: boolean };201active_assignment_sort: { column_name: string; is_descending: boolean };202assignments: AssignmentsMap;203course_filename: string;204course_project_id: string;205configuring_projects?: boolean;206reinviting_students?: boolean;207error?: string;208expanded_students: Set<string>;209expanded_assignments: Set<string>;210expanded_peer_configs: Set<string>;211expanded_handouts: Set<string>;212expanded_skip_gradings: Set<string>;213active_feedback_edits: IsGradingMap;214handouts: HandoutsMap;215loading: boolean; // initially loading the syncdoc from disk.216saving: boolean;217settings: CourseSettingsRecord;218show_save_button: boolean;219students: StudentsMap;220unsaved?: boolean;221terminal_command?: TerminalCommand;222nbgrader_run_info?: NBgraderRunInfo;223// map from student_id to a filter string.224assignmentFilter?: Map<string, string>;225// each page -- students, assignments, handouts (etc.?) has a filter. This is the state of that filter.226pageFilter?: Map<string, string>;227}228229export class CourseStore extends Store<CourseState> {230private assignment_status_cache?: {231[assignment_id: string]: AssignmentStatus;232};233private handout_status_cache?: {234[key: string]: { handout: number; not_handout: number };235};236237// Return true if there are any non-deleted assignments that use peer grading238public any_assignment_uses_peer_grading(): boolean {239for (const [, assignment] of this.get_assignments()) {240if (241assignment.getIn(["peer_grade", "enabled"]) &&242!assignment.get("deleted")243) {244return true;245}246}247return false;248}249250// Return Javascript array of the student_id's of the students251// that graded the given student, or undefined if no relevant assignment.252public get_peers_that_graded_student(253assignment_id: string,254student_id: string,255): string[] {256const peers: string[] = [];257const assignment = this.get_assignment(assignment_id);258if (assignment == null) return peers;259const map = assignment.getIn(["peer_grade", "map"]);260if (map == null) {261return peers;262}263for (const [other_student_id, who_grading] of map) {264if (who_grading.includes(student_id)) {265peers.push(other_student_id as string); // typescript thinks it could be a number?266}267}268return peers;269}270271public 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.274return this.getIn(["settings", "shared_project_id"]);275}276277public get_pay(): string | Date {278const settings = this.get("settings");279if (settings == null || !settings.get("student_pay")) return "";280const pay = settings.get("pay");281if (!pay) return "";282return pay;283}284285public get_payInfo(): PurchaseInfo | null {286const settings = this.get("settings");287if (settings == null || !settings.get("student_pay")) return null;288const payInfo = settings.get("payInfo")?.toJS();289if (!payInfo) return null;290// merge in defaults for backward compat if e.g., no version set291return {292...DEFAULT_PURCHASE_INFO,293...payInfo,294};295}296297public get_datastore(): Datastore {298const settings = this.get("settings");299if (settings == null || settings.get("datastore") == null) return undefined;300const ds = settings.get("datastore");301if (typeof ds === "boolean" || Array.isArray(ds)) {302return ds;303} else {304console.warn(`course/get_datastore: encountered faulty value:`, ds);305return undefined;306}307}308309public get_envvars(): EnvVars | undefined {310const envvars: unknown = this.getIn(["settings", "envvars"]);311if (envvars == null) return undefined;312if (typeof (envvars as any)?.toJS === "function") {313return (envvars as any).toJS();314} else {315console.warn(`course/get_envvars: encountered faulty value:`, envvars);316return;317}318}319320public get_allow_collabs(): boolean {321return !!this.getIn(["settings", "allow_collabs"]);322}323324public get_email_invite(): string {325const invite = this.getIn(["settings", "email_invite"]);326if (invite) return invite;327const site_name = redux.getStore("customize").get("site_name") ?? SITE_NAME;328return `Hello!\n\nWe will use ${site_name} for the course *{title}*.\n\nPlease sign up!\n\n--\n\n{name}`;329}330331public get_students(): StudentsMap {332return this.get("students");333}334335// Return the student's name as a string, using a336// bunch of heuristics to try to present the best337// reasonable name, given what we know. For example,338// it uses an instructor-given custom name if it was set.339public get_student_name(student_id: string): string {340const { student } = this.resolve({ student_id });341if (student == null) {342// Student does not exist at all in store -- this shouldn't happen343return "Unknown Student";344}345// Try instructor assigned name:346if (student.get("first_name")?.trim() || student.get("last_name")?.trim()) {347return [348student.get("first_name", "")?.trim(),349student.get("last_name", "")?.trim(),350].join(" ");351}352const account_id = student.get("account_id");353if (account_id == null) {354// Student doesn't have an account yet on CoCalc (that we know about).355// Email address:356if (student.has("email_address")) {357return student.get("email_address")!;358}359// One of the above had to work, since we add students by email or account.360// But put this in anyways:361return "Unknown Student";362}363// Now we have a student with a known CoCalc account.364// We would have returned early above if there was an instructor assigned name,365// so we just return their name from cocalc, if known.366const users = this.redux.getStore("users");367if (users == null) throw Error("users must be defined");368const name = users.get_name(account_id);369if (name?.trim()) return name;370// This situation usually shouldn't happen, but maybe could in case the user was known but371// then removed themselves as a collaborator, or something else odd.372if (student.has("email_address")) {373return student.get("email_address")!;374}375// OK, now there is really no way to identify this student. I suppose this could376// happen if the student was added by searching for their name, then they removed377// themselves. Nothing useful we can do at this point.378return "Unknown Student";379}380381// Returns student name as with get_student_name above,382// but also include an email address in angle braces,383// if one is known in a full version of the name.384// This is purely meant to provide a bit of extra info385// for the instructor, and not actually used to send emails.386public get_student_name_extra(student_id: string): {387simple: string;388full: string;389} {390const { student } = this.resolve({ student_id });391if (student == null) {392return { simple: "Unknown", full: "Unknown Student" };393}394const email = student.get("email_address");395const simple = this.get_student_name(student_id);396let extra: string = "";397if (398(student.has("first_name") || student.has("last_name")) &&399student.has("account_id")400) {401const users = this.redux.getStore("users");402if (users != null) {403const name = users.get_name(student.get("account_id"));404if (name != null) {405extra = ` (You call them "${student.get("first_name")} ${student.get(406"last_name",407)}", but they call themselves "${name}".)`;408}409}410}411return { simple, full: email ? `${simple} <${email}>${extra}` : simple };412}413414// Return a name that should sort in a sensible way in415// alphabetical order. This is mainly used for CSV export,416// and is not something that will ever get looked at.417public get_student_sort_name(student_id: string): string {418const { student } = this.resolve({ student_id });419if (student == null) {420return student_id; // keeps the sort stable421}422if (student.has("first_name") || student.has("last_name")) {423return [student.get("last_name", ""), student.get("first_name", "")].join(424" ",425);426}427const account_id = student.get("account_id");428if (account_id == null) {429if (student.has("email_address")) {430return student.get("email_address")!;431}432return student_id;433}434const users = this.redux.getStore("users");435if (users == null) return student_id;436return [437users.get_last_name(account_id),438users.get_first_name(account_id),439].join(" ");440}441442public get_student_email(student_id: string): string {443return this.getIn(["students", student_id, "email_address"], "");444}445446public get_student_ids(opts: { deleted?: boolean } = {}): string[] {447const v: string[] = [];448opts.deleted = !!opts.deleted;449for (const [student_id, val] of this.get("students")) {450if (!!val.get("deleted") == opts.deleted) {451v.push(student_id);452}453}454return v;455}456457// return list of all student projects458public get_student_project_ids(459opts: {460include_deleted?: boolean;461deleted_only?: boolean;462} = {},463): string[] {464// include_deleted = if true, also include deleted projects465// deleted_only = if true, only include deleted projects466const { include_deleted, deleted_only } = opts;467468let v: string[] = [];469470for (const [, val] of this.get("students")) {471const project_id = val.get("project_id");472if (!project_id) {473continue;474}475if (deleted_only) {476if (include_deleted && val.get("deleted")) {477v.push(project_id);478}479} else if (include_deleted) {480v.push(project_id);481} else if (!val.get("deleted")) {482v.push(project_id);483}484}485return v;486}487488public get_student(student_id: string): StudentRecord | undefined {489// return student with given id490return this.getIn(["students", student_id]);491}492493public get_student_project_id(student_id: string): string | undefined {494return this.getIn(["students", student_id, "project_id"]);495}496497// Return a Javascript array of immutable.js StudentRecord maps, sorted498// by sort name (so first last name).499public get_sorted_students(): StudentRecord[] {500const v: StudentRecord[] = [];501for (const [, student] of this.get("students")) {502if (!student.get("deleted")) {503v.push(student);504}505}506v.sort((a, b) =>507cmp(508this.get_student_sort_name(a.get("student_id")),509this.get_student_sort_name(b.get("student_id")),510),511);512return v;513}514515public get_grade(assignment_id: string, student_id: string): string {516const { assignment } = this.resolve({ assignment_id });517if (assignment == null) return "";518const r = assignment.getIn(["grades", student_id], "");519return r == null ? "" : r;520}521522public get_nbgrader_scores(523assignment_id: string,524student_id: string,525): { [ipynb: string]: NotebookScores | string } | undefined {526const { assignment } = this.resolve({ assignment_id });527return assignment?.getIn(["nbgrader_scores", student_id])?.toJS();528}529530public get_nbgrader_score_ids(531assignment_id: string,532): { [ipynb: string]: string[] } | undefined {533const { assignment } = this.resolve({ assignment_id });534const ids = assignment?.get("nbgrader_score_ids")?.toJS();535if (ids != null) return ids;536// TODO: If the score id's aren't known, it would be nice to try537// to parse the master ipynb file and compute them. We still538// allow for the possibility that this fails and return undefined539// in that case. This is painful since it involves async calls540// to the backend, and the code that does this as part of grading541// is deep inside other functions. The list we return here542// is always assumed to be used on a "best effort" basis, so this543// is at worst annoying.544}545546public get_comments(assignment_id: string, student_id: string): string {547const { assignment } = this.resolve({ assignment_id });548if (assignment == null) return "";549const r = assignment.getIn(["comments", student_id], "");550return r == null ? "" : r;551}552553public get_due_date(assignment_id: string): Date | undefined {554const { assignment } = this.resolve({ assignment_id });555if (assignment == null) return;556const due_date = assignment.get("due_date");557if (due_date != null) {558return new Date(due_date);559}560}561562public get_assignments(): AssignmentsMap {563return this.get("assignments");564}565566public get_sorted_assignments(): AssignmentRecord[] {567const v: AssignmentRecord[] = [];568for (const [, assignment] of this.get_assignments()) {569if (!assignment.get("deleted")) {570v.push(assignment);571}572}573const f = function (a: AssignmentRecord) {574return [a.get("due_date", 0), a.get("path", "")];575};576v.sort((a, b) => cmp_array(f(a), f(b)));577return v;578}579580// return assignment with given id if a string; otherwise, just return581// the latest version of the assignment as stored in the store.582public get_assignment(assignment_id: string): AssignmentRecord | undefined {583return this.getIn(["assignments", assignment_id]);584}585586public get_assignment_ids({587deleted = false,588}: {589// if deleted is true return only deleted assignments590deleted?: boolean;591} = {}): string[] {592const v: string[] = [];593for (const [assignment_id, val] of this.get_assignments()) {594if (!!val.get("deleted") == deleted) {595v.push(assignment_id);596}597}598return v;599}600601private num_nondeleted(a): number {602let n: number = 0;603for (const [, x] of a) {604if (!x.get("deleted")) {605n += 1;606}607}608return n;609}610611// number of non-deleted students612public num_students(): number {613return this.num_nondeleted(this.get_students());614}615616// number of student projects that are currently running617public num_running_projects(project_map): number {618let n = 0;619for (const [, student] of this.get_students()) {620if (!student.get("deleted")) {621if (622project_map.getIn([student.get("project_id"), "state", "state"]) ==623"running"624) {625n += 1;626}627}628}629return n;630}631632// number of non-deleted assignments633public num_assignments(): number {634return this.num_nondeleted(this.get_assignments());635}636637// number of non-deleted handouts638public num_handouts(): number {639return this.num_nondeleted(this.get_handouts());640}641642// get info about relation between a student and a given assignment643public student_assignment_info(644student_id: string,645assignment_id: string,646): {647last_assignment?: LastCopyInfo;648last_collect?: LastCopyInfo;649last_peer_assignment?: LastCopyInfo;650last_peer_collect?: LastCopyInfo;651last_return_graded?: LastCopyInfo;652student_id: string;653assignment_id: string;654peer_assignment: boolean;655peer_collect: boolean;656} {657const { assignment } = this.resolve({ assignment_id });658if (assignment == null) {659return {660student_id,661assignment_id,662peer_assignment: false,663peer_collect: false,664};665}666667const status = this.get_assignment_status(assignment_id);668if (status == null) throw Error("bug"); // can't happen669670// Important to return undefined if no info -- assumed in code671function get_info(field: string): undefined | LastCopyInfo {672if (assignment == null) throw Error("bug"); // can't happen673const x = assignment.getIn([field, student_id]);674if (x == null) return;675return (x as any).toJS();676}677678const peer_assignment =679status.not_collect + status.not_assignment == 0 && status.collect != 0;680const peer_collect =681status.not_peer_assignment != null && status.not_peer_assignment == 0;682683return {684last_assignment: get_info("last_assignment"),685last_collect: get_info("last_collect"),686last_peer_assignment: get_info("last_peer_assignment"),687last_peer_collect: get_info("last_peer_collect"),688last_return_graded: get_info("last_return_graded"),689student_id,690assignment_id,691peer_assignment,692peer_collect,693};694}695696// Return true if the assignment was copied to/from the697// student, in the given step of the workflow.698// Even an attempt to copy with an error counts,699// unless no_error is true, in which case it doesn't.700public last_copied(701step: AssignmentCopyStep,702assignment_id: string,703student_id: string,704no_error?: boolean,705): boolean {706const x = this.getIn([707"assignments",708assignment_id,709`last_${step}`,710student_id,711]);712if (x == null) {713return false;714}715const y: TypedMap<LastCopyInfo> = x;716if (no_error && y.get("error")) {717return false;718}719return y.get("time") != null;720}721722public has_grade(assignment_id: string, student_id: string): boolean {723return !!this.getIn(["assignments", assignment_id, "grades", student_id]);724}725726public get_assignment_status(727assignment_id: string,728): AssignmentStatus | undefined {729//730// Compute and return an object that has fields (deleted students are ignored)731//732// assignment - number of students who have received assignment includes733// all students if skip_assignment is true734// not_assignment - number of students who have NOT received assignment735// always 0 if skip_assignment is true736// collect - number of students from whom we have collected assignment includes737// all students if skip_collect is true738// not_collect - number of students from whom we have NOT collected assignment but we sent it to them739// always 0 if skip_assignment is true740// peer_assignment - number of students who have received peer assignment741// (only present if peer grading enabled; similar for peer below)742// not_peer_assignment - number of students who have NOT received peer assignment743// peer_collect - number of students from whom we have collected peer grading744// not_peer_collect - number of students from whom we have NOT collected peer grading745// return_graded - number of students to whom we've returned assignment746// not_return_graded - number of students to whom we've NOT returned assignment747// but we collected it from them *and* either assigned a grade or skip grading748//749// This function caches its result and only recomputes values when the store changes,750// so it should be safe to call in render.751//752if (this.assignment_status_cache == null) {753this.assignment_status_cache = {};754this.on("change", () => {755// clear cache on any change to the store756this.assignment_status_cache = {};757});758}759const { assignment } = this.resolve({ assignment_id });760if (assignment == null) {761return;762}763764if (this.assignment_status_cache[assignment_id] != null) {765// we have cached info766return this.assignment_status_cache[assignment_id];767}768769const students: string[] = this.get_student_ids({ deleted: false });770771// Is peer grading enabled?772const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);773const skip_grading: boolean = assignment.get("skip_grading", false);774775const obj: any = {};776for (const t of STEPS(peer)) {777obj[t] = 0;778obj[`not_${t}`] = 0;779}780const info: AssignmentStatus = obj;781for (const student_id of students) {782let previous: boolean = true;783for (const t of STEPS(peer)) {784const x = assignment.getIn([`last_${t}`, student_id]) as785| undefined786| TypedMap<LastCopyInfo>;787if (788(x != null && !x.get("error") && !x.get("start")) ||789assignment.get(`skip_${t}`)790) {791previous = true;792info[t] += 1;793} else {794// add 1 only if the previous step *was* done (and in795// the case of returning, they have a grade)796const graded =797this.has_grade(assignment_id, student_id) || skip_grading;798if (previous && (t !== "return_graded" || graded)) {799info[`not_${t}`] += 1;800}801previous = false;802}803}804}805806this.assignment_status_cache[assignment_id] = info;807return info;808}809810public get_handouts(): HandoutsMap {811return this.get("handouts");812}813814public get_handout(handout_id: string): HandoutRecord | undefined {815return this.getIn(["handouts", handout_id]);816}817818public get_handout_ids({819deleted = false,820}: { deleted?: boolean } = {}): string[] {821const v: string[] = [];822for (const [handout_id, val] of this.get_handouts()) {823if (!!val.get("deleted") == deleted) {824v.push(handout_id);825}826}827return v;828}829830public student_handout_info(831student_id: string,832handout_id: string,833): { status?: LastCopyInfo; handout_id: string; student_id: string } {834// status -- important to be undefined if no info -- assumed in code835const status = this.getIn(["handouts", handout_id, "status", student_id]);836return {837status: status != null ? status.toJS() : undefined,838student_id,839handout_id,840};841}842843// Return the last time the handout was copied to/from the844// student (in the given step of the workflow), or undefined.845// Even an attempt to copy with an error counts.846public handout_last_copied(handout_id: string, student_id: string): boolean {847const x = this.getIn(["handouts", handout_id, "status", student_id]) as848| TypedMap<LastCopyInfo>849| undefined;850if (x == null) {851return false;852}853if (x.get("error")) {854return false;855}856return x.get("time") != null;857}858859public get_handout_status(860handout_id: string,861): undefined | { handout: number; not_handout: number } {862//863// Compute and return an object that has fields (deleted students are ignored)864//865// handout - number of students who have received handout866// not_handout - number of students who have NOT received handout867// This function caches its result and only recomputes values when the store changes,868// so it should be safe to call in render.869//870if (this.handout_status_cache == null) {871this.handout_status_cache = {};872this.on("change", () => {873// clear cache on any change to the store874this.handout_status_cache = {};875});876}877const { handout } = this.resolve({ handout_id });878if (handout == null) {879return undefined;880}881882if (this.handout_status_cache[handout_id] != null) {883return this.handout_status_cache[handout_id];884}885886const students: string[] = this.get_student_ids({ deleted: false });887888const info = {889handout: 0,890not_handout: 0,891};892893const status = handout.get("status");894for (const student_id of students) {895if (status == null) {896info.not_handout += 1;897} else {898const x = status.get(student_id);899if (x != null && !x.get("error")) {900info.handout += 1;901} else {902info.not_handout += 1;903}904}905}906907this.handout_status_cache[handout_id] = info;908return info;909}910911public get_upgrade_plan(upgrade_goal: UpgradeGoal) {912const account_store: any = this.redux.getStore("account");913const project_map = this.redux.getStore("projects").get("project_map");914if (project_map == null) throw Error("not fully loaded");915const plan = project_upgrades.upgrade_plan({916account_id: account_store.get_account_id(),917purchased_upgrades: account_store.get_total_upgrades(),918project_map,919student_project_ids: set(920this.get_student_project_ids({921include_deleted: true,922}),923),924deleted_project_ids: set(925this.get_student_project_ids({926include_deleted: true,927deleted_only: true,928}),929),930upgrade_goal,931});932return plan;933}934935private resolve(opts: {936assignment_id?: string;937student_id?: string;938handout_id?: string;939}): {940student?: StudentRecord;941assignment?: AssignmentRecord;942handout?: HandoutRecord;943} {944const actions = this.redux.getActions(this.name);945if (actions == null) return {};946const x = (actions as CourseActions).resolve(opts);947delete (x as any).store;948return x;949}950951// List of ids of (non-deleted) assignments that have been952// assigned to at least one student.953public get_assigned_assignment_ids(): string[] {954const v: string[] = [];955for (const [assignment_id, val] of this.get_assignments()) {956if (val.get("deleted")) continue;957const x = val.get(`last_assignment`);958if (x != null && x.size > 0) {959v.push(assignment_id);960}961}962return v;963}964965// List of ids of (non-deleted) handouts that have been copied966// out to at least one student.967public get_assigned_handout_ids(): string[] {968const v: string[] = [];969for (const [handout_id, val] of this.get_handouts()) {970if (val.get("deleted")) continue;971const x = val.get(`status`);972if (x != null && x.size > 0) {973v.push(handout_id);974}975}976return v;977}978979public get_copy_parallel(): number {980const n = this.getIn(["settings", "copy_parallel"]) ?? PARALLEL_DEFAULT;981if (n < 1) return 1;982if (n > MAX_COPY_PARALLEL) return MAX_COPY_PARALLEL;983return n;984}985986public get_nbgrader_parallel(): number {987const n = this.getIn(["settings", "nbgrader_parallel"]) ?? PARALLEL_DEFAULT;988if (n < 1) return 1;989if (n > 50) return 50;990return n;991}992993public async getLicenses(force?: boolean): Promise<{994[license_id: string]: { expired: boolean; runLimit: number };995}> {996const licenses: {997[license_id: string]: { expired: boolean; runLimit: number };998} = {};999const license_ids = this.getIn(["settings", "site_license_id"]) ?? "";1000for (const license_id of license_ids.split(",")) {1001if (!license_id) continue;1002try {1003const license_info = await site_license_public_info(license_id, force);1004if (license_info == null) continue;1005const { expires, run_limit } = license_info;1006const expired = !!(expires && expires <= new Date());1007const runLimit = run_limit ? run_limit : 999999999999999; // effectively unlimited1008licenses[license_id] = { expired, runLimit };1009} catch (err) {1010console.warn(`Error getting license info for ${license_id}`, err);1011}1012}1013return licenses;1014}10151016getUnit = (id: string) => {1017return this.getIn(["assignments", id]) ?? this.getIn(["handouts", id]);1018};1019}10201021export function get_nbgrader_score(scores: {1022[ipynb: string]: NotebookScores | string;1023}): { score: number; points: number; error?: boolean; manual_needed: boolean } {1024let points: number = 0;1025let score: number = 0;1026let error: boolean = false;1027let manual_needed: boolean = false;1028for (const ipynb in scores) {1029const x = scores[ipynb];1030if (typeof x == "string") {1031error = true;1032continue;1033}1034for (const grade_id in x) {1035const y = x[grade_id];1036if (y.score == null && y.manual) {1037manual_needed = true;1038}1039if (y.score) {1040score += y.score;1041}1042points += y.points;1043}1044}1045return { score, points, error, manual_needed };1046}104710481049