Path: blob/master/src/packages/frontend/course/assignments/actions.ts
1503 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Actions involving working with assignments:7- assigning, collecting, setting feedback, etc.8*/910import { delay, map } from "awaiting";11import { Map } from "immutable";12import { debounce } from "lodash";13import { join } from "path";14import { redux } from "@cocalc/frontend/app-framework";15import {16exec,17start_project,18stop_project,19} from "@cocalc/frontend/frame-editors/generic/client";20import {21jupyter_strip_notebook,22nbgrader,23} from "@cocalc/frontend/jupyter/nbgrader/api";24import {25extract_auto_scores,26NotebookScores,27} from "@cocalc/frontend/jupyter/nbgrader/autograde";28import { ipynb_clear_hidden_tests } from "@cocalc/frontend/jupyter/nbgrader/clear-hidden-tests";29import { webapp_client } from "@cocalc/frontend/webapp-client";30import {31defaults,32endswith,33len,34path_split,35peer_grading,36split,37trunc,38uuid,39} from "@cocalc/util/misc";40import { CourseActions } from "../actions";41import { COPY_TIMEOUT_MS } from "../consts";42import { export_assignment } from "../export/export-assignment";43import { export_student_file_use_times } from "../export/file-use-times";44import { grading_state } from "../nbgrader/util";45import {46AssignmentRecord,47CourseStore,48get_nbgrader_score,49NBgraderRunInfo,50} from "../store";51import {52AssignmentCopyType,53copy_type_to_last,54LastAssignmentCopyType,55SyncDBRecord,56SyncDBRecordAssignment,57} from "../types";58import {59assignment_identifier,60autograded_filename,61previous_step,62} from "../util";63import {64NBGRADER_CELL_TIMEOUT_MS,65NBGRADER_MAX_OUTPUT,66NBGRADER_MAX_OUTPUT_PER_CELL,67NBGRADER_TIMEOUT_MS,68PEER_GRADING_GUIDE_FILENAME,69PEER_GRADING_GUIDELINES_COMMENT_MARKER,70PEER_GRADING_GUIDELINES_GRADE_MARKER,71STUDENT_SUBDIR,72} from "./consts";73import { DUE_DATE_FILENAME } from "../common/consts";7475const UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS = 3000;7677export class AssignmentsActions {78private course_actions: CourseActions;7980constructor(course_actions: CourseActions) {81this.course_actions = course_actions;82}8384private get_store = (): CourseStore => {85return this.course_actions.get_store();86};8788private collect_path = (path: string): string => {89const store = this.get_store();90if (store == undefined) {91throw Error("store must be defined");92}93const i = store.get("course_filename").lastIndexOf(".");94return store.get("course_filename").slice(0, i) + "-collect/" + path;95};9697// slight warning -- this is linear in the number of assignments (so do not overuse)98private getAssignmentWithPath = (99path: string,100): AssignmentRecord | undefined => {101const store = this.get_store();102if (store == null) return;103return store104.get("assignments")105.valueSeq()106.filter((x) => x.get("path") == path)107.get(0);108};109110addAssignment = async (path: string | string[]): Promise<void> => {111// Add one or more assignment to the course, which is defined by giving a directory in the project.112// Where we collect homework that students have done (in teacher project).113// If the assignment was previously deleted, this undeletes it.114if (typeof path != "string") {115// handle case of array of inputs116for (const p of path) {117await this.addAssignment(p);118}119return;120}121const cur = this.getAssignmentWithPath(path);122if (cur != null) {123// either undelete or nothing to do.124if (cur.get("deleted")) {125// undelete126this.undelete_assignment(cur.get("assignment_id"));127} else {128// nothing to do129}130return;131}132133const collect_path = this.collect_path(path);134const path_parts = path_split(path);135// folder that we return graded homework to (in student project)136const beginning = path_parts.head ? "/graded-" : "graded-";137const graded_path = path_parts.head + beginning + path_parts.tail;138// folder where we copy the assignment to139const target_path = path;140141try {142// Ensure the path actually exists.143await exec({144project_id: this.get_store().get("course_project_id"),145command: "mkdir",146args: ["-p", path],147err_on_exit: true,148});149} catch (err) {150this.course_actions.set_error(`error creating assignment: ${err}`);151return;152}153this.course_actions.set({154path,155collect_path,156graded_path,157target_path,158table: "assignments",159assignment_id: uuid(),160});161};162163delete_assignment = (assignment_id: string): void => {164this.course_actions.set({165deleted: true,166assignment_id,167table: "assignments",168});169};170171undelete_assignment = (assignment_id: string): void => {172this.course_actions.set({173deleted: false,174assignment_id,175table: "assignments",176});177};178179clear_edited_feedback = (assignment_id: string, student_id: string): void => {180const store = this.get_store();181let active_feedback_edits = store.get("active_feedback_edits");182active_feedback_edits = active_feedback_edits.delete(183assignment_identifier(assignment_id, student_id),184);185this.course_actions.setState({ active_feedback_edits });186};187188update_edited_feedback = (assignment_id: string, student_id: string) => {189const store = this.get_store();190const key = assignment_identifier(assignment_id, student_id);191const old_edited_feedback = store.get("active_feedback_edits");192const new_edited_feedback = old_edited_feedback.set(key, true);193this.course_actions.setState({194active_feedback_edits: new_edited_feedback,195});196};197198// Set a specific grade for a student in an assignment.199// This overlaps with save_feedback, but is more200// direct and uses that maybe the user isn't manually editing201// this. E.g., nbgrader uses this to automatically set the grade.202set_grade = (203assignment_id: string,204student_id: string,205grade: string,206commit: boolean = true,207): void => {208const { assignment } = this.course_actions.resolve({209assignment_id,210});211if (assignment == null) {212throw Error("no such assignment");213}214// Annoying that we have to convert to JS here and cast,215// but the set below seems to require it.216let grades = assignment.get("grades", Map()).toJS() as {217[student_id: string]: string;218};219grades[student_id] = grade;220this.course_actions.set(221{222table: "assignments",223assignment_id,224grades,225},226commit,227);228};229230// Set a specific comment for a student in an assignment.231set_comment = (232assignment_id: string,233student_id: string,234comment: string,235commit: boolean = true,236): void => {237const { assignment } = this.course_actions.resolve({238assignment_id,239});240if (assignment == null) {241throw Error("no such assignment");242}243// Annoying that we have to convert to JS here and cast,244// but the set below seems to require it.245let comments = assignment.get("comments", Map()).toJS() as {246[student_id: string]: string;247};248comments[student_id] = comment;249this.course_actions.set(250{251table: "assignments",252assignment_id,253comments,254},255commit,256);257};258259set_active_assignment_sort = (column_name: string): void => {260let is_descending;261const store = this.get_store();262const current_column = store.getIn([263"active_assignment_sort",264"column_name",265]);266if (current_column === column_name) {267is_descending = !store.getIn(["active_assignment_sort", "is_descending"]);268} else {269is_descending = false;270}271this.course_actions.setState({272active_assignment_sort: { column_name, is_descending },273});274};275276private set_assignment_field = (277assignment_id: string,278name: string,279val,280): void => {281this.course_actions.set({282[name]: val,283table: "assignments",284assignment_id,285});286};287288set_due_date = async (289assignment_id: string,290due_date: Date | string | undefined | null,291): Promise<void> => {292const { assignment } = this.course_actions.resolve({293assignment_id,294});295if (assignment == null) {296return;297}298const prev_due_date = assignment.get("due_date");299300if (!due_date) {301// deleting it302if (prev_due_date) {303// not deleted so delete it304this.set_assignment_field(assignment_id, "due_date", null);305this.updateDueDateFile(assignment_id);306}307return;308}309310if (typeof due_date !== "string") {311due_date = due_date.toISOString(); // using strings instead of ms for backward compatibility.312}313314if (prev_due_date == due_date) {315// nothing to do.316return;317}318319this.set_assignment_field(assignment_id, "due_date", due_date);320// it changed, so update the file in all student projects that have already been assigned321// https://github.com/sagemathinc/cocalc/issues/2929322// NOTE: updateDueDate is debounced, so if set_due_date is called a lot, then the323// actual update only happens after it stabilizes for a while. Also, we can be324// sure the store has updated the assignment.325this.updateDueDateFile(assignment_id);326};327328private updateDueDateFile = debounce(async (assignment_id: string) => {329// important to check actions due to debounce.330if (this.course_actions.is_closed()) return;331await this.copy_assignment_create_due_date_file(assignment_id);332if (this.course_actions.is_closed()) return;333334const desc = `Copying modified ${DUE_DATE_FILENAME} to all students who have already received it`;335const short_desc = `copy ${DUE_DATE_FILENAME}`;336337// by default, doesn't create the due file338await this.assignment_action_all_students({339assignment_id,340old_only: true,341action: this.writeDueDateFile,342step: "assignment",343desc,344short_desc,345});346}, UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS);347348private writeDueDateFile = async (349assignment_id: string,350student_id: string,351) => {352const { student, assignment } = this.course_actions.resolve({353assignment_id,354student_id,355});356if (!student || !assignment) return;357const content = this.dueDateFileContent(assignment_id);358const project_id = student.get("project_id");359if (!project_id) return;360const path = join(assignment.get("target_path"), DUE_DATE_FILENAME);361console.log({362project_id,363path,364content,365});366await webapp_client.project_client.write_text_file({367project_id,368path,369content,370});371};372373set_assignment_note = (assignment_id: string, note: string): void => {374this.set_assignment_field(assignment_id, "note", note);375};376377set_peer_grade = (assignment_id: string, config): void => {378const store = this.get_store();379const a = store.get_assignment(assignment_id);380if (a == null) return;381let cur: any = a.get("peer_grade");382cur = cur == null ? {} : cur.toJS();383for (const k in config) {384const v = config[k];385cur[k] = v;386}387this.set_assignment_field(assignment_id, "peer_grade", cur);388};389390set_skip = (assignment_id: string, step: string, value: boolean): void => {391this.set_assignment_field(assignment_id, "skip_" + step, value);392};393394// Synchronous function that makes the peer grading map for the given395// assignment, if it hasn't already been made.396private update_peer_assignment = (assignment_id: string) => {397const { store, assignment } = this.course_actions.resolve({398assignment_id,399});400if (!assignment) return;401const peers = assignment.getIn(["peer_grade", "map"]);402if (peers != null) {403return peers.toJS();404}405const N = assignment.getIn(["peer_grade", "number"], 1);406const map = peer_grading(store.get_student_ids(), N);407this.set_peer_grade(assignment_id, { map });408return map;409};410411// Copy the files for the given assignment_id from the given student to the412// corresponding collection folder.413// If the store is initialized and the student and assignment both exist,414// then calling this action will result in this getting set in the store:415//416// assignment.last_collect[student_id] = {time:?, error:err}417//418// where time >= now is the current time in milliseconds.419private copy_assignment_from_student = async (420assignment_id: string,421student_id: string,422): Promise<void> => {423if (this.start_copy(assignment_id, student_id, "last_collect")) {424return;425}426const id = this.course_actions.set_activity({427desc: "Copying assignment from a student",428});429const finish = (err) => {430this.course_actions.clear_activity(id);431this.finish_copy(assignment_id, student_id, "last_collect", err);432if (err) {433this.course_actions.set_error(`copy from student: ${err}`);434}435};436const { store, student, assignment } = this.course_actions.resolve({437assignment_id,438student_id,439finish,440});441if (!student || !assignment) return;442const student_name = store.get_student_name(student_id);443const student_project_id = student.get("project_id");444if (student_project_id == null) {445// nothing to do446this.course_actions.clear_activity(id);447return;448}449const target_path = join(450assignment.get("collect_path"),451student.get("student_id"),452);453this.course_actions.set_activity({454id,455desc: `Copying assignment from ${student_name}`,456});457try {458await webapp_client.project_client.copy_path_between_projects({459src_project_id: student_project_id,460src_path: assignment.get("target_path"),461target_project_id: store.get("course_project_id"),462target_path,463overwrite_newer: true,464backup: true,465delete_missing: false,466timeout: COPY_TIMEOUT_MS,467});468// write their name to a file469const name = store.get_student_name_extra(student_id);470await this.write_text_file_to_course_project({471path: target_path + `/STUDENT - ${name.simple}.txt`,472content: `This student is ${name.full}.`,473});474finish("");475} catch (err) {476finish(err);477}478};479480// Copy the graded files for the given assignment_id back to the student in a -graded folder.481// If the store is initialized and the student and assignment both exist,482// then calling this action will result in this getting set in the store:483//484// assignment.last_return_graded[student_id] = {time:?, error:err}485//486// where time >= now is the current time in milliseconds.487488private return_assignment_to_student = async (489assignment_id: string,490student_id: string,491): Promise<void> => {492if (this.start_copy(assignment_id, student_id, "last_return_graded")) {493return;494}495const id: number = this.course_actions.set_activity({496desc: "Returning assignment to a student",497});498const finish = (err) => {499this.course_actions.clear_activity(id);500this.finish_copy(assignment_id, student_id, "last_return_graded", err);501if (err) {502this.course_actions.set_error(`return to student: ${err}`);503}504};505const { store, student, assignment } = this.course_actions.resolve({506assignment_id,507student_id,508finish,509});510if (!student || !assignment) return;511const grade = store.get_grade(assignment_id, student_id);512const comments = store.get_comments(assignment_id, student_id);513const student_name = store.get_student_name(student_id);514const student_project_id = student.get("project_id");515516// if skip_grading is true, this means there *might* no be a "grade" given,517// but instead some grading inside the files or an external tool is used.518// therefore, only create the grade file if this is false.519const skip_grading = assignment.get("skip_grading", false);520521if (student_project_id == null) {522// nothing to do523this.course_actions.clear_activity(id);524return;525}526527let peer_graded;528this.course_actions.set_activity({529id,530desc: `Returning assignment to ${student_name}`,531});532let src_path = assignment.get("collect_path");533if (assignment.getIn(["peer_grade", "enabled"])) {534peer_graded = true;535src_path += "-peer-grade/";536} else {537peer_graded = false;538}539src_path = join(src_path, student.get("student_id"));540let content;541if (skip_grading && !peer_graded) {542content =543"Your instructor is doing grading outside CoCalc, or there is no grading for this assignment.";544} else {545if (grade || peer_graded) {546content = "# Your grade";547} else {548content = "";549}550}551// write their grade to a file552if (grade) {553// likely undefined when skip_grading true & peer_graded true554content += `\n\n${grade}`;555}556if (comments != null && comments.trim().length > 0) {557content += `\n\n# Instructor comments\n\n${comments}`;558}559if (peer_graded) {560content += `\561\n\n\n# Peer graded\n\n562Your assignment was peer graded by other students.563You can find the comments they made above and any directly to your work in the folders below.\564`;565}566567const nbgrader_scores = store.get_nbgrader_scores(568assignment_id,569student_id,570);571const nbgrader_score_ids = store.get_nbgrader_score_ids(assignment_id);572if (nbgrader_scores) {573const { score, points, error } = get_nbgrader_score(nbgrader_scores);574const summary = error ? "error" : `${score}/${points}`;575576let details: string = "";577for (const filename in nbgrader_scores) {578details += `\n\n**${filename}:**\n\n`;579const s = nbgrader_scores[filename];580if (typeof s == "string") {581details += `ERROR: ${s}\n\n`;582} else {583details += `| Problem | Score |\n|:----------|:----------|\n`;584const ids: string[] = nbgrader_score_ids?.[filename] ?? [];585for (const id in s) {586if (!ids.includes(id)) {587ids.push(id);588}589}590for (const id of ids) {591if (s[id] != null) {592const t = `${s[id]?.score ?? 0}`;593details += `| ${id.padEnd(10)}| ${t.padEnd(10)}|\n`;594}595}596}597}598599// TODO: make this nicer, especially the details.600content += `\601\n\n# nbgrader\n602Your notebook was automatically graded using nbgrader, with603possible additional instructor tests.604605TOTAL SCORE: ${summary}606607## nbgrader details608${details}609`;610}611612try {613await this.write_text_file_to_course_project({614path: src_path + "/GRADE.md",615content,616});617await webapp_client.project_client.copy_path_between_projects({618src_project_id: store.get("course_project_id"),619src_path,620target_project_id: student_project_id,621target_path: assignment.get("graded_path"),622overwrite_newer: true,623backup: true,624delete_missing: false,625exclude: peer_graded ? ["*GRADER*.txt"] : undefined,626timeout: COPY_TIMEOUT_MS,627});628finish("");629} catch (err) {630finish(err);631}632};633634// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.635return_assignment_to_all_students = async (636assignment_id: string,637new_only: boolean,638): Promise<void> => {639const id = this.course_actions.set_activity({640desc:641"Returning assignments to all students " + new_only642? "who have not already received it"643: "",644});645const finish = (err) => {646this.course_actions.clear_activity(id);647this.course_actions.set_error(`return to student: ${err}`);648};649const { store, assignment } = this.course_actions.resolve({650assignment_id,651finish,652});653if (!assignment) return;654let errors: string = "";655const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);656const skip_grading: boolean = assignment.get("skip_grading", false);657const f: (student_id: string) => Promise<void> = async (student_id) => {658if (this.course_actions.is_closed()) return;659if (660!store.last_copied(661previous_step("return_graded", peer),662assignment_id,663student_id,664true,665)666) {667// we never collected the assignment from this student668return;669}670const has_grade = store.has_grade(assignment_id, student_id);671if (!skip_grading && !has_grade) {672// we collected and do grade, but didn't grade it yet673return;674}675if (new_only) {676if (677store.last_copied("return_graded", assignment_id, student_id, true) &&678(skip_grading || has_grade)679) {680// it was already returned681return;682}683}684try {685await this.return_assignment_to_student(assignment_id, student_id);686} catch (err) {687errors += `\n ${err}`;688}689};690691await map(692store.get_student_ids({ deleted: false }),693store.get_copy_parallel(),694f,695);696if (errors) {697finish(errors);698} else {699this.course_actions.clear_activity(id);700}701};702703private finish_copy = (704assignment_id: string,705student_id: string,706type: LastAssignmentCopyType,707err: any,708): void => {709const obj: SyncDBRecord = {710table: "assignments",711assignment_id,712};713const a = this.course_actions.get_one(obj);714if (a == null) return;715const x = a[type] ? a[type] : {};716if (err) {717x[student_id] = { error: err };718} else {719x[student_id] = { time: webapp_client.server_time() };720}721obj[type] = x;722this.course_actions.set(obj);723};724725// This is called internally before doing any copy/collection operation726// to ensure that we aren't doing the same thing repeatedly, and that727// everything is in place to do the operation.728private start_copy = (729assignment_id: string,730student_id: string,731type: LastAssignmentCopyType,732): boolean => {733const obj: SyncDBRecordAssignment = {734table: "assignments",735assignment_id,736};737const assignment_latest = this.course_actions.get_one(obj);738if (assignment_latest == null) return false; // assignment gone739let x = assignment_latest[type];740if (x == null) x = {};741let y = x[student_id];742if (y == null) y = {};743if (y.start != null && webapp_client.server_time() - y.start <= 15000) {744return true; // never retry a copy until at least 15 seconds later.745}746y.start = webapp_client.server_time();747if (y.error) {748// clear error when initiating copy749y.error = "";750}751x[student_id] = y;752obj[type] = x;753this.course_actions.set(obj);754return false;755};756757private stop_copy = (758assignment_id: string,759student_id: string,760type: LastAssignmentCopyType,761): void => {762const obj: SyncDBRecordAssignment = {763table: "assignments",764assignment_id,765};766const a = this.course_actions.get_one(obj);767if (a == null) return;768const x = a[type];769if (x == null) return;770const y = x[student_id];771if (y == null) return;772if (y.start != null) {773delete y.start;774x[student_id] = y;775obj[type] = x;776this.course_actions.set(obj);777}778};779780// Copy the files for the given assignment to the given student. If781// the student project doesn't exist yet, it will be created.782// You may also pass in an id for either the assignment or student.783// "overwrite" (boolean, optional): if true, the copy operation will overwrite/delete remote files in student projects -- #1483784// If the store is initialized and the student and assignment both exist,785// then calling this action will result in this getting set in the store:786//787// assignment.last_assignment[student_id] = {time:?, error:err}788//789// where time >= now is the current time in milliseconds.790private copy_assignment_to_student = async (791assignment_id: string,792student_id: string,793opts: object,794): Promise<void> => {795const { overwrite, create_due_date_file } = defaults(opts, {796overwrite: false,797create_due_date_file: false,798});799const { student, assignment, store } = this.course_actions.resolve({800student_id,801assignment_id,802});803if (!student || !assignment) return;804if (assignment.get("nbgrader") && !assignment.get("has_student_subdir")) {805this.course_actions.set_error(806"Assignment contains Jupyter notebooks with nbgrader metadata but there is no student/ subdirectory. The student/ subdirectory gets created when you generate the student version of the assignment. Please generate the student versions of your notebooks (open the notebook, then View --> nbgrader), or remove any nbgrader metadata from them.",807);808return;809}810811if (this.start_copy(assignment_id, student_id, "last_assignment")) {812return;813}814const id = this.course_actions.set_activity({815desc: "Copying assignment to a student",816});817const finish = (err = "") => {818this.course_actions.clear_activity(id);819this.finish_copy(assignment_id, student_id, "last_assignment", err);820if (err) {821this.course_actions.set_error(`copy to student: ${err}`);822}823};824825const student_name = store.get_student_name(student_id);826this.course_actions.set_activity({827id,828desc: `Copying assignment to ${student_name}`,829});830let student_project_id: string | undefined = student.get("project_id");831const src_path = this.assignment_src_path(assignment);832try {833if (student_project_id == null) {834this.course_actions.set_activity({835id,836desc: `${student_name}'s project doesn't exist, so creating it.`,837});838student_project_id =839await this.course_actions.student_projects.create_student_project(840student_id,841);842if (!student_project_id) {843throw Error("failed to create project");844}845}846if (create_due_date_file) {847await this.copy_assignment_create_due_date_file(assignment_id);848}849if (this.course_actions.is_closed()) return;850this.course_actions.set_activity({851id,852desc: `Copying files to ${student_name}'s project`,853});854const opts = {855src_project_id: store.get("course_project_id"),856src_path,857target_project_id: student_project_id,858target_path: assignment.get("target_path"),859overwrite_newer: !!overwrite, // default is "false"860delete_missing: !!overwrite, // default is "false"861backup: !!!overwrite, // default is "true"862timeout: COPY_TIMEOUT_MS,863};864await webapp_client.project_client.copy_path_between_projects(opts);865await this.course_actions.compute.setComputeServerAssociations({866student_id,867src_path: opts.src_path,868target_project_id: opts.target_project_id,869target_path: opts.target_path,870unit_id: assignment_id,871});872873// successful finish874finish();875} catch (err) {876// error somewhere along the way877finish(err);878}879};880881private assignment_src_path = (assignment): string => {882let path = assignment.get("path");883if (assignment.get("has_student_subdir")) {884path = join(path, STUDENT_SUBDIR);885}886return path;887};888889// this is part of the assignment disribution, should be done only *once*, not for every student890private copy_assignment_create_due_date_file = async (891assignment_id: string,892): Promise<void> => {893const { assignment } = this.course_actions.resolve({894assignment_id,895});896if (!assignment) return;897// write the due date to a file898const src_path = this.assignment_src_path(assignment);899const due_id = this.course_actions.set_activity({900desc: `Creating ${DUE_DATE_FILENAME} file...`,901});902const content = this.dueDateFileContent(assignment_id);903const path = join(src_path, DUE_DATE_FILENAME);904905try {906await this.write_text_file_to_course_project({907path,908content,909});910} catch (err) {911throw Error(912`Problem writing ${DUE_DATE_FILENAME} file ('${err}'). Try again...`,913);914} finally {915this.course_actions.clear_activity(due_id);916}917};918919private dueDateFileContent = (assignment_id) => {920const due_date = this.get_store()?.get_due_date(assignment_id);921if (due_date) {922return `This assignment is due\n\n ${due_date.toLocaleString()}`;923} else {924return "No due date has been set.";925}926};927928copy_assignment = async (929type: AssignmentCopyType,930assignment_id: string,931student_id: string,932): Promise<void> => {933// type = assigned, collected, graded, peer-assigned, peer-collected934switch (type) {935case "assigned":936// make sure listing is up to date, since it sets "has_student_subdir",937// which impacts the distribute semantics.938await this.update_listing(assignment_id);939await this.copy_assignment_to_student(assignment_id, student_id, {940create_due_date_file: true,941});942return;943case "collected":944await this.copy_assignment_from_student(assignment_id, student_id);945return;946case "graded":947await this.return_assignment_to_student(assignment_id, student_id);948return;949case "peer-assigned":950await this.peer_copy_to_student(assignment_id, student_id);951return;952case "peer-collected":953await this.peer_collect_from_student(assignment_id, student_id);954return;955default:956this.course_actions.set_error(957`copy_assignment -- unknown type: ${type}`,958);959return;960}961};962963// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.964copy_assignment_to_all_students = async (965assignment_id: string,966new_only: boolean,967overwrite: boolean,968): Promise<void> => {969const desc = `Copying assignments to all students ${970new_only ? "who have not already received it" : ""971}`;972const short_desc = "copy to student";973await this.update_listing(assignment_id); // make sure this is up to date974if (this.course_actions.is_closed()) return;975await this.copy_assignment_create_due_date_file(assignment_id);976if (this.course_actions.is_closed()) return;977// by default, doesn't create the due file978await this.assignment_action_all_students({979assignment_id,980new_only,981action: this.copy_assignment_to_student,982step: "assignment",983desc,984short_desc,985overwrite,986});987};988989// Copy the given assignment to from all non-deleted students, doing several copies in parallel at once.990copy_assignment_from_all_students = async (991assignment_id: string,992new_only: boolean,993): Promise<void> => {994let desc = "Copying assignment from all students";995if (new_only) {996desc += " from whom we have not already copied it";997}998const short_desc = "copy from student";999await this.assignment_action_all_students({1000assignment_id,1001new_only,1002action: this.copy_assignment_from_student,1003step: "collect",1004desc,1005short_desc,1006});1007};10081009private start_all_for_peer_grading = async (): Promise<void> => {1010// On cocalc.com, if the student projects get started specifically1011// for the purposes of copying files to/from them, then they stop1012// around a minute later. This is very bad for peer grading, since1013// so much copying occurs, and we end up with conflicts between1014// projects starting to peer grade, then stop, then needing to be1015// started again all at once. We thus request that they all start,1016// wait a few seconds for that "reason" for them to be running to1017// take effect, and then do the copy. This way the projects aren't1018// automatically stopped after the copies happen.1019const id = this.course_actions.set_activity({1020desc: "Warming up all student projects for peer grading...",1021});1022this.course_actions.student_projects.action_all_student_projects("start");1023// We request to start all projects simultaneously, and the system1024// will start doing that. I think it's not so much important that1025// the projects are actually running, but that they were started1026// before the copy operations started.1027await delay(5 * 1000);1028this.course_actions.clear_activity(id);1029};10301031async peer_copy_to_all_students(1032assignment_id: string,1033new_only: boolean,1034): Promise<void> {1035let desc = "Copying assignments for peer grading to all students ";1036if (new_only) {1037desc += " who have not already received their copy";1038}1039const short_desc = "copy to student for peer grading";1040// CRITICAL: be sure to run this update once before doing the1041// assignment. Otherwise, since assignment runs more than once1042// in parallel, two will launch at about the same time and1043// the *condition* to know if it is done depends on the store,1044// which defers when it gets updated. Anyway, this line is critical:1045try {1046this.update_peer_assignment(assignment_id);1047} catch (err) {1048this.course_actions.set_error(`${short_desc} -- ${err}`);1049return;1050}1051await this.start_all_for_peer_grading();1052// OK, now do the assignment... in parallel.1053await this.assignment_action_all_students({1054assignment_id,1055new_only,1056action: this.peer_copy_to_student,1057step: "peer_assignment",1058desc,1059short_desc,1060});1061}10621063async peer_collect_from_all_students(1064assignment_id: string,1065new_only: boolean,1066): Promise<void> {1067let desc = "Copying peer graded assignments from all students";1068if (new_only) {1069desc += " from whom we have not already copied it";1070}1071const short_desc = "copy peer grading from students";1072await this.start_all_for_peer_grading();1073await this.assignment_action_all_students({1074assignment_id,1075new_only,1076action: this.peer_collect_from_student,1077step: "peer_collect",1078desc,1079short_desc,1080});1081await this.peerParseStudentGrading(assignment_id);1082}10831084private peerParseStudentGrading = async (assignment_id: string) => {1085// For each student do the following:1086// If they already have a recorded grade, do nothing further.1087// If they do not have a recorded grade, load all of the1088// PEER_GRADING_GUIDE_FILENAME files that were collected1089// from the students, then create a grade from that (if possible), along1090// with a comment that explains how that grade was obtained, without1091// saying which student did what.1092const { store, assignment } = this.course_actions.resolve({1093assignment_id,1094});1095if (assignment == null) {1096throw Error("no such assignment");1097}1098const id = this.course_actions.set_activity({1099desc: "Parsing peer grading",1100});1101const allGrades = assignment.get("grades", Map()).toJS() as {1102[student_id: string]: string;1103};1104const allComments = assignment.get("comments", Map()).toJS() as {1105[student_id: string]: string;1106};1107// compute missing grades1108for (const student_id of store.get_student_ids()) {1109if (allGrades[student_id]) {1110// a grade is already set1111continue;1112}1113// attempt to compute a grade1114const peer_student_ids: string[] = store.get_peers_that_graded_student(1115assignment_id,1116student_id,1117);1118const course_project_id = store.get("course_project_id");1119const grades: number[] = [];1120let comments: string[] = [];1121const student_name = store.get_student_name(student_id);1122this.course_actions.set_activity({1123id,1124desc: `Parsing peer grading of ${student_name}`,1125});1126for (const peer_student_id of peer_student_ids) {1127const path = join(1128`${assignment.get("collect_path")}-peer-grade`,1129student_id,1130peer_student_id,1131PEER_GRADING_GUIDE_FILENAME,1132);1133try {1134const contents = await webapp_client.project_client.read_text_file({1135project_id: course_project_id,1136path,1137});1138const i = contents.lastIndexOf(PEER_GRADING_GUIDELINES_GRADE_MARKER);1139if (i == -1) {1140continue;1141}1142let j = contents.lastIndexOf(PEER_GRADING_GUIDELINES_COMMENT_MARKER);1143if (j == -1) {1144j = contents.length;1145}1146const grade = parseFloat(1147contents1148.slice(i + PEER_GRADING_GUIDELINES_GRADE_MARKER.length, j)1149.trim(),1150);1151if (!isFinite(grade) && isNaN(grade)) {1152continue;1153}1154const comment = contents.slice(1155j + PEER_GRADING_GUIDELINES_COMMENT_MARKER.length,1156);1157grades.push(grade);1158comments.push(comment);1159} catch (err) {1160// grade not available for some reason1161console.warn("issue reading peer grading file", {1162path,1163err,1164student_name,1165});1166}1167}1168if (grades.length > 0) {1169const grade = grades.reduce((a, b) => a + b) / grades.length;1170allGrades[student_id] = `${grade}`;1171if (!allComments[student_id]) {1172const studentComments = comments1173.filter((x) => x.trim())1174.map((x) => `- ${x.trim()}`)1175.join("\n\n");1176allComments[student_id] = `Grades: ${grades.join(", ")}\n\n${1177studentComments ? "Student Comments:\n" + studentComments : ""1178}`;1179}1180}1181}1182// set them in the course data1183this.course_actions.set(1184{1185table: "assignments",1186assignment_id,1187grades: allGrades,1188comments: allComments,1189},1190true,1191);1192this.course_actions.clear_activity(id);1193};11941195private assignment_action_all_students = async ({1196assignment_id,1197new_only,1198old_only,1199action,1200step,1201desc,1202short_desc,1203overwrite,1204}: {1205assignment_id: string;1206// only do the action when it hasn't been done already1207new_only?: boolean;1208// only do the action when it HAS been done already1209old_only?: boolean;1210action: (1211assignment_id: string,1212student_id: string,1213opts: any,1214) => Promise<void>;1215step;1216desc;1217short_desc: string;1218overwrite?: boolean;1219}): Promise<void> => {1220if (new_only && old_only) {1221// no matter what, this means the empty set, so nothing to do.1222// Of course no code shouild actually call this.1223return;1224}1225const id = this.course_actions.set_activity({ desc });1226const finish = (err) => {1227this.course_actions.clear_activity(id);1228err = `${short_desc}: ${err}`;1229this.course_actions.set_error(err);1230};1231const { store, assignment } = this.course_actions.resolve({1232assignment_id,1233finish,1234});1235if (!assignment) return;1236let errors = "";1237const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);1238const prev_step =1239step == "assignment" ? undefined : previous_step(step, peer);1240const f = async (student_id: string): Promise<void> => {1241if (this.course_actions.is_closed()) return;1242const store = this.get_store();1243if (1244prev_step != null &&1245!store.last_copied(prev_step, assignment_id, student_id, true)1246) {1247return;1248}1249const alreadyCopied = !!store.last_copied(1250step,1251assignment_id,1252student_id,1253true,1254);1255if (new_only && alreadyCopied) {1256// only for the ones that haven't already been copied1257return;1258}1259if (old_only && !alreadyCopied) {1260// only for the ones that *HAVE* already been copied.1261return;1262}1263try {1264await action(assignment_id, student_id, { overwrite });1265} catch (err) {1266errors += `\n ${err}`;1267}1268};12691270await map(1271store.get_student_ids({ deleted: false }),1272store.get_copy_parallel(),1273f,1274);12751276if (errors) {1277finish(errors);1278} else {1279this.course_actions.clear_activity(id);1280}1281};12821283// Copy the collected folders from some students to the given student for peer grading.1284// Assumes folder is non-empty1285private peer_copy_to_student = async (1286assignment_id: string,1287student_id: string,1288): Promise<void> => {1289if (this.start_copy(assignment_id, student_id, "last_peer_assignment")) {1290return;1291}1292const id = this.course_actions.set_activity({1293desc: "Copying peer grading to a student",1294});1295const finish = (err?) => {1296this.course_actions.clear_activity(id);1297this.finish_copy(assignment_id, student_id, "last_peer_assignment", err);1298if (err) {1299this.course_actions.set_error(`copy peer-grading to student: ${err}`);1300}1301};1302const { store, student, assignment } = this.course_actions.resolve({1303assignment_id,1304student_id,1305finish,1306});1307if (!student || !assignment) return;13081309const student_name = store.get_student_name(student_id);1310this.course_actions.set_activity({1311id,1312desc: `Copying peer grading to ${student_name}`,1313});13141315let peer_map;1316try {1317// synchronous, but could fail, e.g., not enough students1318peer_map = this.update_peer_assignment(assignment_id);1319} catch (err) {1320this.course_actions.set_error(`peer copy to student: ${err}`);1321finish();1322return;1323}13241325if (peer_map == null) {1326finish();1327return;1328}13291330const peers = peer_map[student.get("student_id")];1331if (peers == null) {1332// empty peer assignment for this student (maybe student added after1333// peer assignment already created?)1334finish();1335return;1336}13371338const student_project_id = student.get("project_id");1339if (!student_project_id) {1340finish();1341return;1342}13431344let guidelines: string = assignment.getIn(1345["peer_grade", "guidelines"],1346"Please grade this assignment.",1347);1348const due_date = assignment.getIn(["peer_grade", "due_date"]);1349if (due_date != null) {1350guidelines =1351`GRADING IS DUE ${new Date(due_date).toLocaleString()} \n\n ` +1352guidelines;1353}13541355const target_base_path = assignment.get("path") + "-peer-grade";1356const f = async (peer_student_id: string) => {1357if (this.course_actions.is_closed()) {1358return;1359}1360const src_path = join(assignment.get("collect_path"), peer_student_id);1361// write instructions file for the student, where they enter the grade,1362// and also it tells them what to do.1363await this.write_text_file_to_course_project({1364path: join(src_path, PEER_GRADING_GUIDE_FILENAME),1365content: guidelines,1366});1367const target_path = join(target_base_path, peer_student_id);1368// In the copy below, we exclude the student's name so that1369// peer grading is anonymous; also, remove original1370// due date to avoid confusion.1371// copy the files to be peer graded into place for this student1372await webapp_client.project_client.copy_path_between_projects({1373src_project_id: store.get("course_project_id"),1374src_path,1375target_project_id: student_project_id,1376target_path,1377overwrite_newer: false,1378delete_missing: false,1379exclude: ["*STUDENT*.txt", "*" + DUE_DATE_FILENAME + "*"],1380timeout: COPY_TIMEOUT_MS,1381});1382};13831384try {1385// now copy actual stuff to grade1386await map(peers, store.get_copy_parallel(), f);1387finish();1388} catch (err) {1389finish(err);1390return;1391}1392};13931394// Collect all the peer graading of the given student (not the work the student did, but1395// the grading about the student!).1396private peer_collect_from_student = async (1397assignment_id: string,1398student_id: string,1399): Promise<void> => {1400if (this.start_copy(assignment_id, student_id, "last_peer_collect")) {1401return;1402}1403const id = this.course_actions.set_activity({1404desc: "Collecting peer grading of a student",1405});1406const finish = (err?) => {1407this.course_actions.clear_activity(id);1408this.finish_copy(assignment_id, student_id, "last_peer_collect", err);1409if (err) {1410this.course_actions.set_error(1411`collecting peer-grading of a student: ${err}`,1412);1413}1414};14151416const { store, student, assignment } = this.course_actions.resolve({1417student_id,1418assignment_id,1419finish,1420});1421if (!student || !assignment) return;14221423const student_name = store.get_student_name(student_id);1424this.course_actions.set_activity({1425id,1426desc: `Collecting peer grading of ${student_name}`,1427});14281429// list of student_id of students that graded this student (may be empty)1430const peers: string[] = store.get_peers_that_graded_student(1431assignment_id,1432student_id,1433);14341435const our_student_id = student.get("student_id");14361437const f = async (student_id: string): Promise<void> => {1438const s = store.get_student(student_id);1439// ignore deleted or non-existent students1440if (s == null || s.get("deleted")) return;14411442const path = assignment.get("path");1443const src_path = join(`${path}-peer-grade`, our_student_id);1444const target_path = join(1445`${assignment.get("collect_path")}-peer-grade`,1446our_student_id,1447student_id,1448);14491450const src_project_id = s.get("project_id");1451if (!src_project_id) {1452return;1453}14541455// copy the files over from the student who did the peer grading1456await webapp_client.project_client.copy_path_between_projects({1457src_project_id,1458src_path,1459target_project_id: store.get("course_project_id"),1460target_path,1461overwrite_newer: false,1462delete_missing: false,1463timeout: COPY_TIMEOUT_MS,1464});14651466// write local file identifying the grader1467let name = store.get_student_name_extra(student_id);1468await this.write_text_file_to_course_project({1469path: target_path + `/GRADER - ${name.simple}.txt`,1470content: `The student who did the peer grading is named ${name.full}.`,1471});14721473// write local file identifying student being graded1474name = store.get_student_name_extra(our_student_id);1475await this.write_text_file_to_course_project({1476path: target_path + `/STUDENT - ${name.simple}.txt`,1477content: `This student is ${name.full}.`,1478});1479};14801481try {1482await map(peers, store.get_copy_parallel(), f);1483finish();1484} catch (err) {1485finish(err);1486}1487};14881489// This doesn't really stop it yet, since that's not supported by the backend.1490// It does stop the spinner and let the user try to restart the copy.1491stop_copying_assignment = (1492assignment_id: string,1493student_id: string,1494type: AssignmentCopyType,1495): void => {1496this.stop_copy(assignment_id, student_id, copy_type_to_last(type));1497};14981499open_assignment = (1500type: AssignmentCopyType,1501assignment_id: string,1502student_id: string,1503): void => {1504const { store, assignment, student } = this.course_actions.resolve({1505assignment_id,1506student_id,1507});1508if (assignment == null || student == null) return;1509const student_project_id = student.get("project_id");1510if (student_project_id == null) {1511this.course_actions.set_error(1512"open_assignment: student project not yet created",1513);1514return;1515}1516// Figure out what to open1517let path, proj;1518switch (type) {1519case "assigned": // where project was copied in the student's project.1520path = assignment.get("target_path");1521proj = student_project_id;1522break;1523case "collected": // where collected locally1524path = join(assignment.get("collect_path"), student.get("student_id")); // TODO: refactor1525proj = store.get("course_project_id");1526break;1527case "peer-assigned": // where peer-assigned (in student's project)1528proj = student_project_id;1529path = assignment.get("path") + "-peer-grade";1530break;1531case "peer-collected": // where collected peer-graded work (in our project)1532path =1533assignment.get("collect_path") +1534"-peer-grade/" +1535student.get("student_id");1536proj = store.get("course_project_id");1537break;1538case "graded": // where project returned1539path = assignment.get("graded_path"); // refactor1540proj = student_project_id;1541break;1542default:1543this.course_actions.set_error(1544`open_assignment -- unknown type: ${type}`,1545);1546}1547if (proj == null) {1548this.course_actions.set_error("no such project");1549return;1550}1551// Now open it1552redux.getProjectActions(proj).open_directory(path);1553};15541555private write_text_file_to_course_project = async (opts: {1556path: string;1557content: string;1558}): Promise<void> => {1559await webapp_client.project_client.write_text_file({1560project_id: this.get_store().get("course_project_id"),1561path: opts.path,1562content: opts.content,1563});1564};15651566// Update datastore with directory listing of non-hidden content of the assignment.1567// This also sets whether or not there is a STUDENT_SUBDIR directory.1568update_listing = async (assignment_id: string): Promise<void> => {1569const { store, assignment } = this.course_actions.resolve({1570assignment_id,1571});1572if (assignment == null) return;1573const project_id = store.get("course_project_id");1574const path = assignment.get("path");1575if (project_id == null || path == null) return;1576let listing;1577try {1578const { files } = await webapp_client.project_client.directory_listing({1579project_id,1580path,1581hidden: false,1582compute_server_id: 0, // TODO1583});1584listing = files;1585} catch (err) {1586// This might happen, e.g., if the assignment directory is deleted or user messes1587// with permissions...1588// In this case, just give up.1589return;1590}1591if (listing == null || this.course_actions.is_closed()) return;1592this.course_actions.set({1593listing,1594assignment_id,1595table: "assignments",1596});15971598let has_student_subdir: boolean = false;1599for (const entry of listing) {1600if (entry.isdir && entry.name == STUDENT_SUBDIR) {1601has_student_subdir = true;1602break;1603}1604}1605const nbgrader = await this.has_nbgrader_metadata(assignment_id);1606if (this.course_actions.is_closed()) return;1607this.course_actions.set({1608has_student_subdir,1609nbgrader,1610assignment_id,1611table: "assignments",1612});1613};16141615/* Scan all Jupyter notebooks in the top level of either the assignment directory or1616the student/1617subdirectory of it for cells with nbgrader metadata. If any are found, return1618true; otherwise, return false.1619*/1620private has_nbgrader_metadata = async (1621assignment_id: string,1622): Promise<boolean> => {1623return len(await this.nbgrader_instructor_ipynb_files(assignment_id)) > 0;1624};16251626// Read in the (stripped) contents of all nbgrader instructor ipynb1627// files for this assignment. These are:1628// - Every ipynb file in the assignment directory that has a cell that1629// contains nbgrader metadata (and isn't mangled).1630private nbgrader_instructor_ipynb_files = async (1631assignment_id: string,1632): Promise<{ [path: string]: string }> => {1633const { store, assignment } = this.course_actions.resolve({1634assignment_id,1635});1636if (assignment == null) {1637return {}; // nothing case.1638}1639const path = assignment.get("path");1640const project_id = store.get("course_project_id");1641let files;1642try {1643files = await redux1644.getProjectStore(project_id)1645.get_listings()1646.getListingDirectly(path);1647} catch (err) {1648// This happens, e.g., if the instructor moves the directory1649// that contains their version of the ipynb file.1650// See https://github.com/sagemathinc/cocalc/issues/55011651const error = `Unable to find the directory where you created this assignment. If you moved or renamed it, please move or copy it back to "${path}", then try again. (${err})`;1652this.course_actions.set_error(error);1653throw err;1654}1655const result: { [path: string]: string } = {};16561657if (this.course_actions.is_closed()) return result;16581659const to_read = files1660.filter((entry) => !entry.isdir && endswith(entry.name, ".ipynb"))1661.map((entry) => entry.name);16621663const f: (file: string) => Promise<void> = async (file) => {1664if (this.course_actions.is_closed()) return;1665const fullpath = path != "" ? join(path, file) : file;1666try {1667const content = await jupyter_strip_notebook(project_id, fullpath);1668const { cells } = JSON.parse(content);1669for (const cell of cells) {1670if (cell.metadata.nbgrader) {1671result[file] = content;1672return;1673}1674}1675} catch (err) {1676return;1677}1678};16791680await map(to_read, 10, f);1681return result;1682};16831684// Run nbgrader for all students for which this assignment1685// has been collected at least once.1686run_nbgrader_for_all_students = async (1687assignment_id: string,1688ungraded_only?: boolean,1689): Promise<void> => {1690// console.log("run_nbgrader_for_all_students", assignment_id);1691const instructor_ipynb_files =1692await this.nbgrader_instructor_ipynb_files(assignment_id);1693if (this.course_actions.is_closed()) return;1694const store = this.get_store();1695const nbgrader_scores = store.getIn([1696"assignments",1697assignment_id,1698"nbgrader_scores",1699]);1700const one_student: (student_id: string) => Promise<void> = async (1701student_id,1702) => {1703if (this.course_actions.is_closed()) return;1704if (!store.last_copied("collect", assignment_id, student_id, true)) {1705// Do not try to grade the assignment, since it wasn't1706// already successfully collected yet.1707return;1708}1709if (1710ungraded_only &&1711grading_state(student_id, nbgrader_scores) == "succeeded"1712) {1713// Do not try to grade assignment, if it has already been successfully graded.1714return;1715}1716await this.run_nbgrader_for_one_student(1717assignment_id,1718student_id,1719instructor_ipynb_files,1720true,1721);1722};1723try {1724this.nbgrader_set_is_running(assignment_id);1725await map(1726this.get_store().get_student_ids({ deleted: false }),1727this.get_store().get_nbgrader_parallel(),1728one_student,1729);1730this.course_actions.syncdb.commit();1731} finally {1732this.nbgrader_set_is_done(assignment_id);1733}1734};17351736set_nbgrader_scores_for_all_students = ({1737assignment_id,1738force,1739commit,1740}: {1741assignment_id: string;1742force?: boolean;1743commit?: boolean;1744}): void => {1745for (const student_id of this.get_store().get_student_ids({1746deleted: false,1747})) {1748this.set_grade_using_nbgrader_if_possible(1749assignment_id,1750student_id,1751false,1752force,1753);1754}1755if (commit) {1756this.course_actions.syncdb.commit();1757}1758};17591760set_nbgrader_scores_for_one_student = (1761assignment_id: string,1762student_id: string,1763scores: { [filename: string]: NotebookScores | string },1764nbgrader_score_ids:1765| { [filename: string]: string[] }1766| undefined = undefined,1767commit: boolean = true,1768): void => {1769const assignment_data = this.course_actions.get_one({1770table: "assignments",1771assignment_id,1772});1773if (assignment_data == null) return;1774const nbgrader_scores: {1775[student_id: string]: { [ipynb: string]: NotebookScores | string };1776} = assignment_data.nbgrader_scores || {};1777nbgrader_scores[student_id] = scores;1778this.course_actions.set(1779{1780table: "assignments",1781assignment_id,1782nbgrader_scores,1783...(nbgrader_score_ids != null ? { nbgrader_score_ids } : undefined),1784},1785commit,1786);1787this.set_grade_using_nbgrader_if_possible(1788assignment_id,1789student_id,1790commit,1791);1792};17931794set_specific_nbgrader_score = (1795assignment_id: string,1796student_id: string,1797filename: string,1798grade_id: string,1799score: number,1800commit: boolean = true,1801): void => {1802const { assignment } = this.course_actions.resolve({1803assignment_id,1804});1805if (assignment == null) {1806throw Error("no such assignment");1807}18081809const scores: any = assignment1810.getIn(["nbgrader_scores", student_id], Map())1811.toJS();1812let x: any = scores[filename];1813if (x == null) {1814x = scores[filename] = {};1815}1816let y = x[grade_id];1817if (y == null) {1818y = x[grade_id] = {};1819}1820y.score = score;1821if (y.points != null && y.score > y.points) {1822y.score = y.points;1823}1824if (y.score < 0) {1825y.score = 0;1826}1827this.set_nbgrader_scores_for_one_student(1828assignment_id,1829student_id,1830scores,1831undefined,1832commit,1833);18341835this.set_grade_using_nbgrader_if_possible(1836assignment_id,1837student_id,1838commit,1839);1840};18411842// Fill in manual grade if it is blank and there is an nbgrader grade1843// and all the manual nbgrader scores have been filled in.1844// Also, the filled in grade uses a specific format [number]/[total]1845// and if this is maintained and the nbgrader scores change, this1846// the manual grade is updated.1847set_grade_using_nbgrader_if_possible = (1848assignment_id: string,1849student_id: string,1850commit: boolean = true,1851force: boolean = false,1852): void => {1853// Check if nbgrader scores are all available.1854const store = this.get_store();1855const scores = store.get_nbgrader_scores(assignment_id, student_id);1856if (scores == null) {1857// no info -- maybe nbgrader not even run yet.1858return;1859}1860const { score, points, error, manual_needed } = get_nbgrader_score(scores);1861if (!force && (error || manual_needed)) {1862// more work must be done before we can use this.1863return;1864}18651866// Fill in the overall grade if either it is currently unset, blank,1867// or of the form [number]/[number].1868const grade = store.get_grade(assignment_id, student_id).trim();1869if (force || grade == "" || grade.match(/\d+\/\d+/g)) {1870this.set_grade(assignment_id, student_id, `${score}/${points}`, commit);1871}1872};18731874run_nbgrader_for_one_student = async (1875assignment_id: string,1876student_id: string,1877instructor_ipynb_files?: { [path: string]: string },1878commit: boolean = true,1879): Promise<void> => {1880// console.log("run_nbgrader_for_one_student", assignment_id, student_id);18811882const { store, assignment, student } = this.course_actions.resolve({1883assignment_id,1884student_id,1885});18861887if (1888student == null ||1889assignment == null ||1890!assignment.get("has_student_subdir")1891) {1892return; // nothing case.1893}18941895const nbgrader_grade_project: string | undefined = store.getIn([1896"settings",1897"nbgrader_grade_project",1898]);18991900const nbgrader_include_hidden_tests: boolean = !!store.getIn([1901"settings",1902"nbgrader_include_hidden_tests",1903]);19041905const course_project_id = store.get("course_project_id");1906const student_project_id = student.get("project_id");19071908let grade_project_id: string;1909let student_path: string;1910let stop_student_project = false;1911if (nbgrader_grade_project) {1912grade_project_id = nbgrader_grade_project;19131914// grade in the path where we collected their work.1915student_path = join(1916assignment.get("collect_path"),1917student.get("student_id"),1918);19191920this.course_actions.configuration.configure_nbgrader_grade_project(1921grade_project_id,1922);1923} else {1924if (student_project_id == null) {1925// This would happen if maybe instructor deletes student project at1926// the exact wrong time.1927// TODO: just create a new project for them?1928throw Error("student has no project, so can't run nbgrader");1929}1930grade_project_id = student_project_id;1931// grade right where student did their work.1932student_path = assignment.get("target_path");1933}19341935const where_grade =1936redux.getStore("projects").get_title(grade_project_id) ?? "a project";19371938const project_name = nbgrader_grade_project1939? `project ${trunc(where_grade, 40)}`1940: `${store.get_student_name(student_id)}'s project`;19411942if (instructor_ipynb_files == null) {1943instructor_ipynb_files =1944await this.nbgrader_instructor_ipynb_files(assignment_id);1945if (this.course_actions.is_closed()) return;1946}1947if (len(instructor_ipynb_files) == 0) {1948/* console.log(1949"run_nbgrader_for_one_student",1950assignment_id,1951student_id,1952"done -- no ipynb files"1953); */1954return; // nothing to do1955}19561957const result: { [path: string]: any } = {};1958const scores: { [filename: string]: NotebookScores | string } = {};19591960const one_file: (file: string) => Promise<void> = async (file) => {1961const activity_id = this.course_actions.set_activity({1962desc: `Running nbgrader on ${store.get_student_name(1963student_id,1964)}'s "${file}" in '${trunc(where_grade, 40)}'`,1965});1966if (assignment == null || student == null) {1967// This won't happen, but it makes Typescript happy.1968return;1969}1970try {1971// fullpath = where their collected work is.1972const fullpath = join(1973assignment.get("collect_path"),1974student.get("student_id"),1975file,1976);1977const student_ipynb: string = await jupyter_strip_notebook(1978course_project_id,1979fullpath,1980);1981if (instructor_ipynb_files == null) throw Error("BUG");1982const instructor_ipynb: string = instructor_ipynb_files[file];1983if (this.course_actions.is_closed()) return;19841985const id = this.course_actions.set_activity({1986desc: `Ensuring ${project_name} is running`,1987});19881989try {1990const did_start = await start_project(grade_project_id, 60);1991// if *we* started the student project, we'll also stop it afterwards1992if (!nbgrader_grade_project) {1993stop_student_project = did_start;1994}1995} finally {1996this.course_actions.clear_activity(id);1997}19981999let ephemeralGradePath;2000try {2001if (2002grade_project_id != course_project_id &&2003grade_project_id != student_project_id2004) {2005ephemeralGradePath = true;2006// Make a fresh copy of the assignment files to the grade project.2007// This is necessary because grading the assignment may depend on2008// data files that are sent as part of the assignment. Also,2009// student's might have some code in text files next to the ipynb.2010await webapp_client.project_client.copy_path_between_projects({2011src_project_id: course_project_id,2012src_path: student_path,2013target_project_id: grade_project_id,2014target_path: student_path,2015overwrite_newer: true,2016delete_missing: true,2017backup: false,2018timeout: COPY_TIMEOUT_MS,2019});2020} else {2021ephemeralGradePath = false;2022}20232024const opts = {2025timeout_ms: store.getIn(2026["settings", "nbgrader_timeout_ms"],2027NBGRADER_TIMEOUT_MS,2028),2029cell_timeout_ms: store.getIn(2030["settings", "nbgrader_cell_timeout_ms"],2031NBGRADER_CELL_TIMEOUT_MS,2032),2033max_output: store.getIn(2034["settings", "nbgrader_max_output"],2035NBGRADER_MAX_OUTPUT,2036),2037max_output_per_cell: store.getIn(2038["settings", "nbgrader_max_output_per_cell"],2039NBGRADER_MAX_OUTPUT_PER_CELL,2040),2041student_ipynb,2042instructor_ipynb,2043path: student_path,2044project_id: grade_project_id,2045};2046/*console.log(2047student_id,2048file,2049"about to launch autograding with input ",2050opts2051);*/2052const r = await nbgrader(opts);2053/* console.log(student_id, "autograding finished successfully", {2054file,2055r,2056});*/2057result[file] = r;2058} finally {2059if (ephemeralGradePath) {2060await webapp_client.project_client.exec({2061project_id: grade_project_id,2062command: "rm",2063args: ["-rf", student_path],2064});2065}2066}20672068if (!nbgrader_grade_project && stop_student_project) {2069const idstop = this.course_actions.set_activity({2070desc: `Stopping project ${project_name} after grading.`,2071});2072try {2073await stop_project(grade_project_id, 60);2074} finally {2075this.course_actions.clear_activity(idstop);2076}2077}2078} catch (err) {2079// console.log("nbgrader failed", { student_id, file, err });2080scores[file] = `${err}`;2081} finally {2082this.course_actions.clear_activity(activity_id);2083}2084};20852086// NOTE: we *could* run multiple files in parallel, but that causes2087// trouble for very little benefit. It's better to run across all students in parallel,2088// and the trouble is just that running lots of code in the same project can confuse2089// the backend api and use extra memory (which is unfair to students being graded, e.g.,2090// if their project has 1GB of RAM and we run 3 notebooks at once, they get "gypped").2091try {2092this.nbgrader_set_is_running(assignment_id, student_id);20932094for (const file in instructor_ipynb_files) {2095await one_file(file);2096}2097} finally {2098this.nbgrader_set_is_done(assignment_id, student_id);2099}2100/* console.log("ran nbgrader for all files for a student", {2101student_id,2102result2103}); */2104// Save any previous nbgrader scores for this student, so we can2105// preserve any manually entered scores, rather than overwrite them.2106const prev_scores = store.get_nbgrader_scores(assignment_id, student_id);21072108const nbgrader_score_ids: { [filename: string]: string[] } = {};21092110for (const filename in result) {2111const r = result[filename];2112if (r == null) continue;2113if (r.output == null) continue;2114if (r.ids != null) {2115nbgrader_score_ids[filename] = r.ids;2116}21172118// Depending on instructor options, write the graded version of2119// the notebook to disk, so the student can see why their grade2120// is what it is:2121const notebook = JSON.parse(r.output);2122scores[filename] = extract_auto_scores(notebook);2123if (2124prev_scores != null &&2125prev_scores[filename] != null &&2126typeof prev_scores[filename] != "string"2127) {2128// preserve any manual scores. cast since for some reason the typeof above isn't enough.2129for (const id in prev_scores[filename] as object) {2130const x = prev_scores[filename][id];2131if (x.manual && x.score && scores[filename][id] != null) {2132scores[filename][id].score = x.score;2133}2134}2135}21362137if (!nbgrader_include_hidden_tests) {2138// IMPORTANT: this *must* happen after extracting scores above!2139// Otherwise students get perfect grades.2140ipynb_clear_hidden_tests(notebook);2141}21422143await this.write_autograded_notebook(2144assignment,2145student_id,2146filename,2147JSON.stringify(notebook, undefined, 2),2148);2149}21502151this.set_nbgrader_scores_for_one_student(2152assignment_id,2153student_id,2154scores,2155nbgrader_score_ids,2156commit,2157);2158};21592160autograded_path = (2161assignment: AssignmentRecord,2162student_id: string,2163filename: string,2164): string => {2165return autograded_filename(2166join(assignment.get("collect_path"), student_id, filename),2167);2168};21692170private write_autograded_notebook = async (2171assignment: AssignmentRecord,2172student_id: string,2173filename: string,2174content: string,2175): Promise<void> => {2176const path = this.autograded_path(assignment, student_id, filename);2177await this.write_text_file_to_course_project({ path, content });2178};21792180open_file_in_collected_assignment = async (2181assignment_id: string,2182student_id: string,2183file: string,2184): Promise<void> => {2185const { assignment, student, store } = this.course_actions.resolve({2186assignment_id,2187student_id,2188});2189if (assignment == null || student == null) {2190throw Error("no such student or assignment");2191}2192const course_project_id = store.get("course_project_id");2193const fullpath = join(2194assignment.get("collect_path"),2195student.get("student_id"),2196file,2197);21982199await redux2200.getProjectActions(course_project_id)2201.open_file({ path: fullpath, foreground: true });2202};22032204private nbgrader_set_is_running = (2205assignment_id: string,2206student_id?: string,2207): void => {2208const store = this.get_store();2209let nbgrader_run_info: NBgraderRunInfo = store.get(2210"nbgrader_run_info",2211Map(),2212);2213const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;2214nbgrader_run_info = nbgrader_run_info.set(key, webapp_client.server_time());2215this.course_actions.setState({ nbgrader_run_info });2216};22172218private nbgrader_set_is_done = (2219assignment_id: string,2220student_id?: string,2221): void => {2222const store = this.get_store();2223let nbgrader_run_info: NBgraderRunInfo = store.get(2224"nbgrader_run_info",2225Map<string, number>(),2226);2227const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;2228nbgrader_run_info = nbgrader_run_info.delete(key);2229this.course_actions.setState({ nbgrader_run_info });2230};22312232export_file_use_times = async (2233assignment_id: string,2234json_filename: string,2235): Promise<void> => {2236// Get the path of the assignment2237const { assignment, store } = this.course_actions.resolve({2238assignment_id,2239});2240if (assignment == null) {2241throw Error("no such assignment");2242}2243const src_path = this.assignment_src_path(assignment);2244const target_path = assignment.get("path");2245await export_student_file_use_times(2246store.get("course_project_id"),2247src_path,2248target_path,2249store.get("students"),2250json_filename,2251store.get_student_name.bind(store),2252);2253};22542255export_collected = async (assignment_id: string): Promise<void> => {2256const set_activity = this.course_actions.set_activity.bind(2257this.course_actions,2258);2259const id = set_activity({2260desc: "Exporting collected files...",2261});2262try {2263const { assignment, store } = this.course_actions.resolve({2264assignment_id,2265});2266if (assignment == null) return;2267const students = store.get("students");2268const src_path = this.assignment_src_path(assignment);2269const collect_path = assignment.get("collect_path");2270const i = store.get("course_filename").lastIndexOf(".");2271const base_export_path =2272store.get("course_filename").slice(0, i) + "-export";2273const export_path = join(base_export_path, src_path);22742275const student_name = function (student_id: string): string {2276const v = split(store.get_student_name(student_id));2277return v.join("_");2278};22792280const activity = function (s: string): void {2281set_activity({2282id,2283desc: "Exporting collected files... " + s,2284});2285};22862287const project_id = store.get("course_project_id");22882289await export_assignment(2290project_id,2291collect_path,2292export_path,2293students,2294student_name,2295activity,2296);22972298redux.getProjectActions(project_id).open_directory(base_export_path);2299} catch (err) {2300this.course_actions.set_error(2301`Error exporting collected student files -- ${err}`,2302);2303} finally {2304set_activity({ id });2305}2306};2307}230823092310