Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/course/assignments/actions.ts
Views: 687
/*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";1415import { redux } from "@cocalc/frontend/app-framework";16import {17exec,18start_project,19stop_project,20} from "@cocalc/frontend/frame-editors/generic/client";21import {22jupyter_strip_notebook,23nbgrader,24} from "@cocalc/frontend/jupyter/nbgrader/api";25import {26extract_auto_scores,27NotebookScores,28} from "@cocalc/frontend/jupyter/nbgrader/autograde";29import { ipynb_clear_hidden_tests } from "@cocalc/frontend/jupyter/nbgrader/clear-hidden-tests";30import { webapp_client } from "@cocalc/frontend/webapp-client";31import {32defaults,33endswith,34len,35path_split,36peer_grading,37split,38trunc,39uuid,40} from "@cocalc/util/misc";41import { CourseActions } from "../actions";42import { COPY_TIMEOUT_MS } from "../consts";43import { export_assignment } from "../export/export-assignment";44import { export_student_file_use_times } from "../export/file-use-times";45import { grading_state } from "../nbgrader/util";46import {47AssignmentRecord,48CourseStore,49get_nbgrader_score,50NBgraderRunInfo,51} from "../store";52import {53AssignmentCopyType,54copy_type_to_last,55LastAssignmentCopyType,56SyncDBRecord,57SyncDBRecordAssignment,58} from "../types";59import {60assignment_identifier,61autograded_filename,62previous_step,63} from "../util";64import {65NBGRADER_CELL_TIMEOUT_MS,66NBGRADER_MAX_OUTPUT,67NBGRADER_MAX_OUTPUT_PER_CELL,68NBGRADER_TIMEOUT_MS,69PEER_GRADING_GUIDE_FILENAME,70PEER_GRADING_GUIDELINES_COMMENT_MARKER,71PEER_GRADING_GUIDELINES_GRADE_MARKER,72STUDENT_SUBDIR,73} from "./consts";74import { DUE_DATE_FILENAME } from "../common/consts";7576const UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS = 3000;7778export class AssignmentsActions {79private course_actions: CourseActions;8081constructor(course_actions: CourseActions) {82this.course_actions = course_actions;83}8485private get_store = (): CourseStore => {86return this.course_actions.get_store();87};8889private collect_path = (path: string): string => {90const store = this.get_store();91if (store == undefined) {92throw Error("store must be defined");93}94const i = store.get("course_filename").lastIndexOf(".");95return store.get("course_filename").slice(0, i) + "-collect/" + path;96};9798// slight warning -- this is linear in the number of assignments (so do not overuse)99private getAssignmentWithPath = (100path: string,101): AssignmentRecord | undefined => {102const store = this.get_store();103if (store == null) return;104return store105.get("assignments")106.valueSeq()107.filter((x) => x.get("path") == path)108.get(0);109};110111addAssignment = async (path: string | string[]): Promise<void> => {112// Add one or more assignment to the course, which is defined by giving a directory in the project.113// Where we collect homework that students have done (in teacher project).114// If the assignment was previously deleted, this undeletes it.115if (typeof path != "string") {116// handle case of array of inputs117for (const p of path) {118await this.addAssignment(p);119}120return;121}122const cur = this.getAssignmentWithPath(path);123if (cur != null) {124// either undelete or nothing to do.125if (cur.get("deleted")) {126// undelete127this.undelete_assignment(cur.get("assignment_id"));128} else {129// nothing to do130}131return;132}133134const collect_path = this.collect_path(path);135const path_parts = path_split(path);136// folder that we return graded homework to (in student project)137const beginning = path_parts.head ? "/graded-" : "graded-";138const graded_path = path_parts.head + beginning + path_parts.tail;139// folder where we copy the assignment to140const target_path = path;141142try {143// Ensure the path actually exists.144await exec({145project_id: this.get_store().get("course_project_id"),146command: "mkdir",147args: ["-p", path],148err_on_exit: true,149});150} catch (err) {151this.course_actions.set_error(`error creating assignment: ${err}`);152return;153}154this.course_actions.set({155path,156collect_path,157graded_path,158target_path,159table: "assignments",160assignment_id: uuid(),161});162};163164delete_assignment = (assignment_id: string): void => {165this.course_actions.set({166deleted: true,167assignment_id,168table: "assignments",169});170};171172undelete_assignment = (assignment_id: string): void => {173this.course_actions.set({174deleted: false,175assignment_id,176table: "assignments",177});178};179180clear_edited_feedback = (assignment_id: string, student_id: string): void => {181const store = this.get_store();182let active_feedback_edits = store.get("active_feedback_edits");183active_feedback_edits = active_feedback_edits.delete(184assignment_identifier(assignment_id, student_id),185);186this.course_actions.setState({ active_feedback_edits });187};188189update_edited_feedback = (assignment_id: string, student_id: string) => {190const store = this.get_store();191const key = assignment_identifier(assignment_id, student_id);192const old_edited_feedback = store.get("active_feedback_edits");193const new_edited_feedback = old_edited_feedback.set(key, true);194this.course_actions.setState({195active_feedback_edits: new_edited_feedback,196});197};198199// Set a specific grade for a student in an assignment.200// This overlaps with save_feedback, but is more201// direct and uses that maybe the user isn't manually editing202// this. E.g., nbgrader uses this to automatically set the grade.203set_grade = (204assignment_id: string,205student_id: string,206grade: string,207commit: boolean = true,208): void => {209const { assignment } = this.course_actions.resolve({210assignment_id,211});212if (assignment == null) {213throw Error("no such assignment");214}215// Annoying that we have to convert to JS here and cast,216// but the set below seems to require it.217let grades = assignment.get("grades", Map()).toJS() as {218[student_id: string]: string;219};220grades[student_id] = grade;221this.course_actions.set(222{223table: "assignments",224assignment_id,225grades,226},227commit,228);229};230231// Set a specific comment for a student in an assignment.232set_comment = (233assignment_id: string,234student_id: string,235comment: string,236commit: boolean = true,237): void => {238const { assignment } = this.course_actions.resolve({239assignment_id,240});241if (assignment == null) {242throw Error("no such assignment");243}244// Annoying that we have to convert to JS here and cast,245// but the set below seems to require it.246let comments = assignment.get("comments", Map()).toJS() as {247[student_id: string]: string;248};249comments[student_id] = comment;250this.course_actions.set(251{252table: "assignments",253assignment_id,254comments,255},256commit,257);258};259260set_active_assignment_sort = (column_name: string): void => {261let is_descending;262const store = this.get_store();263const current_column = store.getIn([264"active_assignment_sort",265"column_name",266]);267if (current_column === column_name) {268is_descending = !store.getIn(["active_assignment_sort", "is_descending"]);269} else {270is_descending = false;271}272this.course_actions.setState({273active_assignment_sort: { column_name, is_descending },274});275};276277private set_assignment_field = (278assignment_id: string,279name: string,280val,281): void => {282this.course_actions.set({283[name]: val,284table: "assignments",285assignment_id,286});287};288289set_due_date = async (290assignment_id: string,291due_date: Date | string | undefined | null,292): Promise<void> => {293const { assignment } = this.course_actions.resolve({294assignment_id,295});296if (assignment == null) {297return;298}299const prev_due_date = assignment.get("due_date");300301if (!due_date) {302// deleting it303if (prev_due_date) {304// not deleted so delete it305this.set_assignment_field(assignment_id, "due_date", null);306this.updateDueDateFile(assignment_id);307}308return;309}310311if (typeof due_date !== "string") {312due_date = due_date.toISOString(); // using strings instead of ms for backward compatibility.313}314315if (prev_due_date == due_date) {316// nothing to do.317return;318}319320this.set_assignment_field(assignment_id, "due_date", due_date);321// it changed, so update the file in all student projects that have already been assigned322// https://github.com/sagemathinc/cocalc/issues/2929323// NOTE: updateDueDate is debounced, so if set_due_date is called a lot, then the324// actual update only happens after it stabilizes for a while. Also, we can be325// sure the store has updated the assignment.326this.updateDueDateFile(assignment_id);327};328329private updateDueDateFile = debounce(async (assignment_id: string) => {330// important to check actions due to debounce.331if (this.course_actions.is_closed()) return;332await this.copy_assignment_create_due_date_file(assignment_id);333if (this.course_actions.is_closed()) return;334335const desc = `Copying modified ${DUE_DATE_FILENAME} to all students who have already received it`;336const short_desc = `copy ${DUE_DATE_FILENAME}`;337338// by default, doesn't create the due file339await this.assignment_action_all_students({340assignment_id,341old_only: true,342action: this.writeDueDateFile,343step: "assignment",344desc,345short_desc,346});347}, UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS);348349private writeDueDateFile = async (350assignment_id: string,351student_id: string,352) => {353const { student, assignment } = this.course_actions.resolve({354assignment_id,355student_id,356});357if (!student || !assignment) return;358const content = this.dueDateFileContent(assignment_id);359const project_id = student.get("project_id");360if (!project_id) return;361const path = join(assignment.get("target_path"), DUE_DATE_FILENAME);362console.log({363project_id,364path,365content,366});367await webapp_client.project_client.write_text_file({368project_id,369path,370content,371});372};373374set_assignment_note = (assignment_id: string, note: string): void => {375this.set_assignment_field(assignment_id, "note", note);376};377378set_peer_grade = (assignment_id: string, config): void => {379const store = this.get_store();380const a = store.get_assignment(assignment_id);381if (a == null) return;382let cur: any = a.get("peer_grade");383cur = cur == null ? {} : cur.toJS();384for (const k in config) {385const v = config[k];386cur[k] = v;387}388this.set_assignment_field(assignment_id, "peer_grade", cur);389};390391set_skip = (assignment_id: string, step: string, value: boolean): void => {392this.set_assignment_field(assignment_id, "skip_" + step, value);393};394395// Synchronous function that makes the peer grading map for the given396// assignment, if it hasn't already been made.397private update_peer_assignment = (assignment_id: string) => {398const { store, assignment } = this.course_actions.resolve({399assignment_id,400});401if (!assignment) return;402const peers = assignment.getIn(["peer_grade", "map"]);403if (peers != null) {404return peers.toJS();405}406const N = assignment.getIn(["peer_grade", "number"], 1);407const map = peer_grading(store.get_student_ids(), N);408this.set_peer_grade(assignment_id, { map });409return map;410};411412// Copy the files for the given assignment_id from the given student to the413// corresponding collection folder.414// If the store is initialized and the student and assignment both exist,415// then calling this action will result in this getting set in the store:416//417// assignment.last_collect[student_id] = {time:?, error:err}418//419// where time >= now is the current time in milliseconds.420private copy_assignment_from_student = async (421assignment_id: string,422student_id: string,423): Promise<void> => {424if (this.start_copy(assignment_id, student_id, "last_collect")) {425return;426}427const id = this.course_actions.set_activity({428desc: "Copying assignment from a student",429});430const finish = (err) => {431this.course_actions.clear_activity(id);432this.finish_copy(assignment_id, student_id, "last_collect", err);433if (err) {434this.course_actions.set_error(`copy from student: ${err}`);435}436};437const { store, student, assignment } = this.course_actions.resolve({438assignment_id,439student_id,440finish,441});442if (!student || !assignment) return;443const student_name = store.get_student_name(student_id);444const student_project_id = student.get("project_id");445if (student_project_id == null) {446// nothing to do447this.course_actions.clear_activity(id);448return;449}450const target_path = join(451assignment.get("collect_path"),452student.get("student_id"),453);454this.course_actions.set_activity({455id,456desc: `Copying assignment from ${student_name}`,457});458try {459await webapp_client.project_client.copy_path_between_projects({460src_project_id: student_project_id,461src_path: assignment.get("target_path"),462target_project_id: store.get("course_project_id"),463target_path,464overwrite_newer: true,465backup: true,466delete_missing: false,467timeout: COPY_TIMEOUT_MS / 1000,468});469// write their name to a file470const name = store.get_student_name_extra(student_id);471await this.write_text_file_to_course_project({472path: target_path + `/STUDENT - ${name.simple}.txt`,473content: `This student is ${name.full}.`,474});475finish("");476} catch (err) {477finish(err);478}479};480481// Copy the graded files for the given assignment_id back to the student in a -graded folder.482// If the store is initialized and the student and assignment both exist,483// then calling this action will result in this getting set in the store:484//485// assignment.last_return_graded[student_id] = {time:?, error:err}486//487// where time >= now is the current time in milliseconds.488489private return_assignment_to_student = async (490assignment_id: string,491student_id: string,492): Promise<void> => {493if (this.start_copy(assignment_id, student_id, "last_return_graded")) {494return;495}496const id: number = this.course_actions.set_activity({497desc: "Returning assignment to a student",498});499const finish = (err) => {500this.course_actions.clear_activity(id);501this.finish_copy(assignment_id, student_id, "last_return_graded", err);502if (err) {503this.course_actions.set_error(`return to student: ${err}`);504}505};506const { store, student, assignment } = this.course_actions.resolve({507assignment_id,508student_id,509finish,510});511if (!student || !assignment) return;512const grade = store.get_grade(assignment_id, student_id);513const comments = store.get_comments(assignment_id, student_id);514const student_name = store.get_student_name(student_id);515const student_project_id = student.get("project_id");516517// if skip_grading is true, this means there *might* no be a "grade" given,518// but instead some grading inside the files or an external tool is used.519// therefore, only create the grade file if this is false.520const skip_grading = assignment.get("skip_grading", false);521522if (student_project_id == null) {523// nothing to do524this.course_actions.clear_activity(id);525return;526}527528let peer_graded;529this.course_actions.set_activity({530id,531desc: `Returning assignment to ${student_name}`,532});533let src_path = assignment.get("collect_path");534if (assignment.getIn(["peer_grade", "enabled"])) {535peer_graded = true;536src_path += "-peer-grade/";537} else {538peer_graded = false;539}540src_path = join(src_path, student.get("student_id"));541let content;542if (skip_grading && !peer_graded) {543content =544"Your instructor is doing grading outside CoCalc, or there is no grading for this assignment.";545} else {546if (grade || peer_graded) {547content = "# Your grade";548} else {549content = "";550}551}552// write their grade to a file553if (grade) {554// likely undefined when skip_grading true & peer_graded true555content += `\n\n${grade}`;556}557if (comments != null && comments.trim().length > 0) {558content += `\n\n# Instructor comments\n\n${comments}`;559}560if (peer_graded) {561content += `\562\n\n\n# Peer graded\n\n563Your assignment was peer graded by other students.564You can find the comments they made above and any directly to your work in the folders below.\565`;566}567568const nbgrader_scores = store.get_nbgrader_scores(569assignment_id,570student_id,571);572const nbgrader_score_ids = store.get_nbgrader_score_ids(assignment_id);573if (nbgrader_scores) {574const { score, points, error } = get_nbgrader_score(nbgrader_scores);575const summary = error ? "error" : `${score}/${points}`;576577let details: string = "";578for (const filename in nbgrader_scores) {579details += `\n\n**${filename}:**\n\n`;580const s = nbgrader_scores[filename];581if (typeof s == "string") {582details += `ERROR: ${s}\n\n`;583} else {584details += `| Problem | Score |\n|:----------|:----------|\n`;585const ids: string[] = nbgrader_score_ids?.[filename] ?? [];586for (const id in s) {587if (!ids.includes(id)) {588ids.push(id);589}590}591for (const id of ids) {592if (s[id] != null) {593const t = `${s[id]?.score ?? 0}`;594details += `| ${id.padEnd(10)}| ${t.padEnd(10)}|\n`;595}596}597}598}599600// TODO: make this nicer, especially the details.601content += `\602\n\n# nbgrader\n603Your notebook was automatically graded using nbgrader, with604possible additional instructor tests.605606TOTAL SCORE: ${summary}607608## nbgrader details609${details}610`;611}612613try {614await this.write_text_file_to_course_project({615path: src_path + "/GRADE.md",616content,617});618await webapp_client.project_client.copy_path_between_projects({619src_project_id: store.get("course_project_id"),620src_path,621target_project_id: student_project_id,622target_path: assignment.get("graded_path"),623overwrite_newer: true,624backup: true,625delete_missing: false,626exclude: peer_graded ? ["*GRADER*.txt"] : undefined,627timeout: COPY_TIMEOUT_MS / 1000,628});629finish("");630} catch (err) {631finish(err);632}633};634635// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.636return_assignment_to_all_students = async (637assignment_id: string,638new_only: boolean,639): Promise<void> => {640const id = this.course_actions.set_activity({641desc:642"Returning assignments to all students " + new_only643? "who have not already received it"644: "",645});646const finish = (err) => {647this.course_actions.clear_activity(id);648this.course_actions.set_error(`return to student: ${err}`);649};650const { store, assignment } = this.course_actions.resolve({651assignment_id,652finish,653});654if (!assignment) return;655let errors: string = "";656const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);657const skip_grading: boolean = assignment.get("skip_grading", false);658const f: (student_id: string) => Promise<void> = async (student_id) => {659if (this.course_actions.is_closed()) return;660if (661!store.last_copied(662previous_step("return_graded", peer),663assignment_id,664student_id,665true,666)667) {668// we never collected the assignment from this student669return;670}671const has_grade = store.has_grade(assignment_id, student_id);672if (!skip_grading && !has_grade) {673// we collected and do grade, but didn't grade it yet674return;675}676if (new_only) {677if (678store.last_copied("return_graded", assignment_id, student_id, true) &&679(skip_grading || has_grade)680) {681// it was already returned682return;683}684}685try {686await this.return_assignment_to_student(assignment_id, student_id);687} catch (err) {688errors += `\n ${err}`;689}690};691692await map(693store.get_student_ids({ deleted: false }),694store.get_copy_parallel(),695f,696);697if (errors) {698finish(errors);699} else {700this.course_actions.clear_activity(id);701}702};703704private finish_copy = (705assignment_id: string,706student_id: string,707type: LastAssignmentCopyType,708err: any,709): void => {710const obj: SyncDBRecord = {711table: "assignments",712assignment_id,713};714const a = this.course_actions.get_one(obj);715if (a == null) return;716const x = a[type] ? a[type] : {};717if (err) {718x[student_id] = { error: err };719} else {720x[student_id] = { time: webapp_client.server_time() };721}722obj[type] = x;723this.course_actions.set(obj);724};725726// This is called internally before doing any copy/collection operation727// to ensure that we aren't doing the same thing repeatedly, and that728// everything is in place to do the operation.729private start_copy = (730assignment_id: string,731student_id: string,732type: LastAssignmentCopyType,733): boolean => {734const obj: SyncDBRecordAssignment = {735table: "assignments",736assignment_id,737};738const assignment_latest = this.course_actions.get_one(obj);739if (assignment_latest == null) return false; // assignment gone740let x = assignment_latest[type];741if (x == null) x = {};742let y = x[student_id];743if (y == null) y = {};744if (y.start != null && webapp_client.server_time() - y.start <= 15000) {745return true; // never retry a copy until at least 15 seconds later.746}747y.start = webapp_client.server_time();748if (y.error) {749// clear error when initiating copy750y.error = "";751}752x[student_id] = y;753obj[type] = x;754this.course_actions.set(obj);755return false;756};757758private stop_copy = (759assignment_id: string,760student_id: string,761type: LastAssignmentCopyType,762): void => {763const obj: SyncDBRecordAssignment = {764table: "assignments",765assignment_id,766};767const a = this.course_actions.get_one(obj);768if (a == null) return;769const x = a[type];770if (x == null) return;771const y = x[student_id];772if (y == null) return;773if (y.start != null) {774delete y.start;775x[student_id] = y;776obj[type] = x;777this.course_actions.set(obj);778}779};780781// Copy the files for the given assignment to the given student. If782// the student project doesn't exist yet, it will be created.783// You may also pass in an id for either the assignment or student.784// "overwrite" (boolean, optional): if true, the copy operation will overwrite/delete remote files in student projects -- #1483785// If the store is initialized and the student and assignment both exist,786// then calling this action will result in this getting set in the store:787//788// assignment.last_assignment[student_id] = {time:?, error:err}789//790// where time >= now is the current time in milliseconds.791private copy_assignment_to_student = async (792assignment_id: string,793student_id: string,794opts: object,795): Promise<void> => {796const { overwrite, create_due_date_file } = defaults(opts, {797overwrite: false,798create_due_date_file: false,799});800const { student, assignment, store } = this.course_actions.resolve({801student_id,802assignment_id,803});804if (!student || !assignment) return;805if (assignment.get("nbgrader") && !assignment.get("has_student_subdir")) {806this.course_actions.set_error(807"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.",808);809return;810}811812if (this.start_copy(assignment_id, student_id, "last_assignment")) {813return;814}815const id = this.course_actions.set_activity({816desc: "Copying assignment to a student",817});818const finish = (err = "") => {819this.course_actions.clear_activity(id);820this.finish_copy(assignment_id, student_id, "last_assignment", err);821if (err) {822this.course_actions.set_error(`copy to student: ${err}`);823}824};825826const student_name = store.get_student_name(student_id);827this.course_actions.set_activity({828id,829desc: `Copying assignment to ${student_name}`,830});831let student_project_id: string | undefined = student.get("project_id");832const src_path = this.assignment_src_path(assignment);833try {834if (student_project_id == null) {835this.course_actions.set_activity({836id,837desc: `${student_name}'s project doesn't exist, so creating it.`,838});839student_project_id =840await this.course_actions.student_projects.create_student_project(841student_id,842);843if (!student_project_id) {844throw Error("failed to create project");845}846}847if (create_due_date_file) {848await this.copy_assignment_create_due_date_file(assignment_id);849}850if (this.course_actions.is_closed()) return;851this.course_actions.set_activity({852id,853desc: `Copying files to ${student_name}'s project`,854});855await webapp_client.project_client.copy_path_between_projects({856src_project_id: store.get("course_project_id"),857src_path,858target_project_id: student_project_id,859target_path: assignment.get("target_path"),860overwrite_newer: !!overwrite, // default is "false"861delete_missing: !!overwrite, // default is "false"862backup: !!!overwrite, // default is "true"863timeout: COPY_TIMEOUT_MS / 1000,864});865866// successful finish867finish();868} catch (err) {869// error somewhere along the way870finish(err);871}872};873874private assignment_src_path = (assignment): string => {875let path = assignment.get("path");876if (assignment.get("has_student_subdir")) {877path = join(path, STUDENT_SUBDIR);878}879return path;880};881882// this is part of the assignment disribution, should be done only *once*, not for every student883private copy_assignment_create_due_date_file = async (884assignment_id: string,885): Promise<void> => {886const { assignment } = this.course_actions.resolve({887assignment_id,888});889if (!assignment) return;890// write the due date to a file891const src_path = this.assignment_src_path(assignment);892const due_id = this.course_actions.set_activity({893desc: `Creating ${DUE_DATE_FILENAME} file...`,894});895const content = this.dueDateFileContent(assignment_id);896const path = join(src_path, DUE_DATE_FILENAME);897898try {899await this.write_text_file_to_course_project({900path,901content,902});903} catch (err) {904throw Error(905`Problem writing ${DUE_DATE_FILENAME} file ('${err}'). Try again...`,906);907} finally {908this.course_actions.clear_activity(due_id);909}910};911912private dueDateFileContent = (assignment_id) => {913const due_date = this.get_store()?.get_due_date(assignment_id);914if (due_date) {915return `This assignment is due\n\n ${due_date.toLocaleString()}`;916} else {917return "No due date has been set.";918}919};920921copy_assignment = async (922type: AssignmentCopyType,923assignment_id: string,924student_id: string,925): Promise<void> => {926// type = assigned, collected, graded, peer-assigned, peer-collected927switch (type) {928case "assigned":929// make sure listing is up to date, since it sets "has_student_subdir",930// which impacts the distribute semantics.931await this.update_listing(assignment_id);932await this.copy_assignment_to_student(assignment_id, student_id, {933create_due_date_file: true,934});935return;936case "collected":937await this.copy_assignment_from_student(assignment_id, student_id);938return;939case "graded":940await this.return_assignment_to_student(assignment_id, student_id);941return;942case "peer-assigned":943await this.peer_copy_to_student(assignment_id, student_id);944return;945case "peer-collected":946await this.peer_collect_from_student(assignment_id, student_id);947return;948default:949this.course_actions.set_error(950`copy_assignment -- unknown type: ${type}`,951);952return;953}954};955956// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.957copy_assignment_to_all_students = async (958assignment_id: string,959new_only: boolean,960overwrite: boolean,961): Promise<void> => {962const desc = `Copying assignments to all students ${963new_only ? "who have not already received it" : ""964}`;965const short_desc = "copy to student";966await this.update_listing(assignment_id); // make sure this is up to date967if (this.course_actions.is_closed()) return;968await this.copy_assignment_create_due_date_file(assignment_id);969if (this.course_actions.is_closed()) return;970// by default, doesn't create the due file971await this.assignment_action_all_students({972assignment_id,973new_only,974action: this.copy_assignment_to_student,975step: "assignment",976desc,977short_desc,978overwrite,979});980};981982// Copy the given assignment to from all non-deleted students, doing several copies in parallel at once.983copy_assignment_from_all_students = async (984assignment_id: string,985new_only: boolean,986): Promise<void> => {987let desc = "Copying assignment from all students";988if (new_only) {989desc += " from whom we have not already copied it";990}991const short_desc = "copy from student";992await this.assignment_action_all_students({993assignment_id,994new_only,995action: this.copy_assignment_from_student,996step: "collect",997desc,998short_desc,999});1000};10011002private start_all_for_peer_grading = async (): Promise<void> => {1003// On cocalc.com, if the student projects get started specifically1004// for the purposes of copying files to/from them, then they stop1005// around a minute later. This is very bad for peer grading, since1006// so much copying occurs, and we end up with conflicts between1007// projects starting to peer grade, then stop, then needing to be1008// started again all at once. We thus request that they all start,1009// wait a few seconds for that "reason" for them to be running to1010// take effect, and then do the copy. This way the projects aren't1011// automatically stopped after the copies happen.1012const id = this.course_actions.set_activity({1013desc: "Warming up all student projects for peer grading...",1014});1015this.course_actions.student_projects.action_all_student_projects("start");1016// We request to start all projects simultaneously, and the system1017// will start doing that. I think it's not so much important that1018// the projects are actually running, but that they were started1019// before the copy operations started.1020await delay(5 * 1000);1021this.course_actions.clear_activity(id);1022};10231024async peer_copy_to_all_students(1025assignment_id: string,1026new_only: boolean,1027): Promise<void> {1028let desc = "Copying assignments for peer grading to all students ";1029if (new_only) {1030desc += " who have not already received their copy";1031}1032const short_desc = "copy to student for peer grading";1033// CRITICAL: be sure to run this update once before doing the1034// assignment. Otherwise, since assignment runs more than once1035// in parallel, two will launch at about the same time and1036// the *condition* to know if it is done depends on the store,1037// which defers when it gets updated. Anyway, this line is critical:1038try {1039this.update_peer_assignment(assignment_id);1040} catch (err) {1041this.course_actions.set_error(`${short_desc} -- ${err}`);1042return;1043}1044await this.start_all_for_peer_grading();1045// OK, now do the assignment... in parallel.1046await this.assignment_action_all_students({1047assignment_id,1048new_only,1049action: this.peer_copy_to_student,1050step: "peer_assignment",1051desc,1052short_desc,1053});1054}10551056async peer_collect_from_all_students(1057assignment_id: string,1058new_only: boolean,1059): Promise<void> {1060let desc = "Copying peer graded assignments from all students";1061if (new_only) {1062desc += " from whom we have not already copied it";1063}1064const short_desc = "copy peer grading from students";1065await this.start_all_for_peer_grading();1066await this.assignment_action_all_students({1067assignment_id,1068new_only,1069action: this.peer_collect_from_student,1070step: "peer_collect",1071desc,1072short_desc,1073});1074await this.peerParseStudentGrading(assignment_id);1075}10761077private peerParseStudentGrading = async (assignment_id: string) => {1078// For each student do the following:1079// If they already have a recorded grade, do nothing further.1080// If they do not have a recorded grade, load all of the1081// PEER_GRADING_GUIDE_FILENAME files that were collected1082// from the students, then create a grade from that (if possible), along1083// with a comment that explains how that grade was obtained, without1084// saying which student did what.1085const { store, assignment } = this.course_actions.resolve({1086assignment_id,1087});1088if (assignment == null) {1089throw Error("no such assignment");1090}1091const id = this.course_actions.set_activity({1092desc: "Parsing peer grading",1093});1094const allGrades = assignment.get("grades", Map()).toJS() as {1095[student_id: string]: string;1096};1097const allComments = assignment.get("comments", Map()).toJS() as {1098[student_id: string]: string;1099};1100// compute missing grades1101for (const student_id of store.get_student_ids()) {1102if (allGrades[student_id]) {1103// a grade is already set1104continue;1105}1106// attempt to compute a grade1107const peer_student_ids: string[] = store.get_peers_that_graded_student(1108assignment_id,1109student_id,1110);1111const course_project_id = store.get("course_project_id");1112const grades: number[] = [];1113let comments: string[] = [];1114const student_name = store.get_student_name(student_id);1115this.course_actions.set_activity({1116id,1117desc: `Parsing peer grading of ${student_name}`,1118});1119for (const peer_student_id of peer_student_ids) {1120const path = join(1121`${assignment.get("collect_path")}-peer-grade`,1122student_id,1123peer_student_id,1124PEER_GRADING_GUIDE_FILENAME,1125);1126try {1127const contents = await webapp_client.project_client.read_text_file({1128project_id: course_project_id,1129path,1130});1131const i = contents.lastIndexOf(PEER_GRADING_GUIDELINES_GRADE_MARKER);1132if (i == -1) {1133continue;1134}1135let j = contents.lastIndexOf(PEER_GRADING_GUIDELINES_COMMENT_MARKER);1136if (j == -1) {1137j = contents.length;1138}1139const grade = parseFloat(1140contents1141.slice(i + PEER_GRADING_GUIDELINES_GRADE_MARKER.length, j)1142.trim(),1143);1144if (!isFinite(grade) && isNaN(grade)) {1145continue;1146}1147const comment = contents.slice(1148j + PEER_GRADING_GUIDELINES_COMMENT_MARKER.length,1149);1150grades.push(grade);1151comments.push(comment);1152} catch (err) {1153// grade not available for some reason1154console.warn("issue reading peer grading file", {1155path,1156err,1157student_name,1158});1159}1160}1161if (grades.length > 0) {1162const grade = grades.reduce((a, b) => a + b) / grades.length;1163allGrades[student_id] = `${grade}`;1164if (!allComments[student_id]) {1165const studentComments = comments1166.filter((x) => x.trim())1167.map((x) => `- ${x.trim()}`)1168.join("\n\n");1169allComments[student_id] = `Grades: ${grades.join(", ")}\n\n${1170studentComments ? "Student Comments:\n" + studentComments : ""1171}`;1172}1173}1174}1175// set them in the course data1176this.course_actions.set(1177{1178table: "assignments",1179assignment_id,1180grades: allGrades,1181comments: allComments,1182},1183true,1184);1185this.course_actions.clear_activity(id);1186};11871188private assignment_action_all_students = async ({1189assignment_id,1190new_only,1191old_only,1192action,1193step,1194desc,1195short_desc,1196overwrite,1197}: {1198assignment_id: string;1199// only do the action when it hasn't been done already1200new_only?: boolean;1201// only do the action when it HAS been done already1202old_only?: boolean;1203action: (1204assignment_id: string,1205student_id: string,1206opts: any,1207) => Promise<void>;1208step;1209desc;1210short_desc: string;1211overwrite?: boolean;1212}): Promise<void> => {1213if (new_only && old_only) {1214// no matter what, this means the empty set, so nothing to do.1215// Of course no code shouild actually call this.1216return;1217}1218const id = this.course_actions.set_activity({ desc });1219const finish = (err) => {1220this.course_actions.clear_activity(id);1221err = `${short_desc}: ${err}`;1222this.course_actions.set_error(err);1223};1224const { store, assignment } = this.course_actions.resolve({1225assignment_id,1226finish,1227});1228if (!assignment) return;1229let errors = "";1230const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);1231const prev_step =1232step == "assignment" ? undefined : previous_step(step, peer);1233const f = async (student_id: string): Promise<void> => {1234if (this.course_actions.is_closed()) return;1235const store = this.get_store();1236if (1237prev_step != null &&1238!store.last_copied(prev_step, assignment_id, student_id, true)1239) {1240return;1241}1242const alreadyCopied = !!store.last_copied(1243step,1244assignment_id,1245student_id,1246true,1247);1248if (new_only && alreadyCopied) {1249// only for the ones that haven't already been copied1250return;1251}1252if (old_only && !alreadyCopied) {1253// only for the ones that *HAVE* already been copied.1254return;1255}1256try {1257await action(assignment_id, student_id, { overwrite });1258} catch (err) {1259errors += `\n ${err}`;1260}1261};12621263await map(1264store.get_student_ids({ deleted: false }),1265store.get_copy_parallel(),1266f,1267);12681269if (errors) {1270finish(errors);1271} else {1272this.course_actions.clear_activity(id);1273}1274};12751276// Copy the collected folders from some students to the given student for peer grading.1277// Assumes folder is non-empty1278private peer_copy_to_student = async (1279assignment_id: string,1280student_id: string,1281): Promise<void> => {1282if (this.start_copy(assignment_id, student_id, "last_peer_assignment")) {1283return;1284}1285const id = this.course_actions.set_activity({1286desc: "Copying peer grading to a student",1287});1288const finish = (err?) => {1289this.course_actions.clear_activity(id);1290this.finish_copy(assignment_id, student_id, "last_peer_assignment", err);1291if (err) {1292this.course_actions.set_error(`copy peer-grading to student: ${err}`);1293}1294};1295const { store, student, assignment } = this.course_actions.resolve({1296assignment_id,1297student_id,1298finish,1299});1300if (!student || !assignment) return;13011302const student_name = store.get_student_name(student_id);1303this.course_actions.set_activity({1304id,1305desc: `Copying peer grading to ${student_name}`,1306});13071308let peer_map;1309try {1310// synchronous, but could fail, e.g., not enough students1311peer_map = this.update_peer_assignment(assignment_id);1312} catch (err) {1313this.course_actions.set_error(`peer copy to student: ${err}`);1314finish();1315return;1316}13171318if (peer_map == null) {1319finish();1320return;1321}13221323const peers = peer_map[student.get("student_id")];1324if (peers == null) {1325// empty peer assignment for this student (maybe student added after1326// peer assignment already created?)1327finish();1328return;1329}13301331const student_project_id = student.get("project_id");1332if (!student_project_id) {1333finish();1334return;1335}13361337let guidelines: string = assignment.getIn(1338["peer_grade", "guidelines"],1339"Please grade this assignment.",1340);1341const due_date = assignment.getIn(["peer_grade", "due_date"]);1342if (due_date != null) {1343guidelines =1344`GRADING IS DUE ${new Date(due_date).toLocaleString()} \n\n ` +1345guidelines;1346}13471348const target_base_path = assignment.get("path") + "-peer-grade";1349const f = async (peer_student_id: string) => {1350if (this.course_actions.is_closed()) {1351return;1352}1353const src_path = join(assignment.get("collect_path"), peer_student_id);1354// write instructions file for the student, where they enter the grade,1355// and also it tells them what to do.1356await this.write_text_file_to_course_project({1357path: join(src_path, PEER_GRADING_GUIDE_FILENAME),1358content: guidelines,1359});1360const target_path = join(target_base_path, peer_student_id);1361// In the copy below, we exclude the student's name so that1362// peer grading is anonymous; also, remove original1363// due date to avoid confusion.1364// copy the files to be peer graded into place for this student1365await webapp_client.project_client.copy_path_between_projects({1366src_project_id: store.get("course_project_id"),1367src_path,1368target_project_id: student_project_id,1369target_path,1370overwrite_newer: false,1371delete_missing: false,1372exclude: ["*STUDENT*.txt", "*" + DUE_DATE_FILENAME + "*"],1373timeout: COPY_TIMEOUT_MS / 1000,1374});1375};13761377try {1378// now copy actual stuff to grade1379await map(peers, store.get_copy_parallel(), f);1380finish();1381} catch (err) {1382finish(err);1383return;1384}1385};13861387// Collect all the peer graading of the given student (not the work the student did, but1388// the grading about the student!).1389private peer_collect_from_student = async (1390assignment_id: string,1391student_id: string,1392): Promise<void> => {1393if (this.start_copy(assignment_id, student_id, "last_peer_collect")) {1394return;1395}1396const id = this.course_actions.set_activity({1397desc: "Collecting peer grading of a student",1398});1399const finish = (err?) => {1400this.course_actions.clear_activity(id);1401this.finish_copy(assignment_id, student_id, "last_peer_collect", err);1402if (err) {1403this.course_actions.set_error(1404`collecting peer-grading of a student: ${err}`,1405);1406}1407};14081409const { store, student, assignment } = this.course_actions.resolve({1410student_id,1411assignment_id,1412finish,1413});1414if (!student || !assignment) return;14151416const student_name = store.get_student_name(student_id);1417this.course_actions.set_activity({1418id,1419desc: `Collecting peer grading of ${student_name}`,1420});14211422// list of student_id of students that graded this student (may be empty)1423const peers: string[] = store.get_peers_that_graded_student(1424assignment_id,1425student_id,1426);14271428const our_student_id = student.get("student_id");14291430const f = async (student_id: string): Promise<void> => {1431const s = store.get_student(student_id);1432// ignore deleted or non-existent students1433if (s == null || s.get("deleted")) return;14341435const path = assignment.get("path");1436const src_path = join(`${path}-peer-grade`, our_student_id);1437const target_path = join(1438`${assignment.get("collect_path")}-peer-grade`,1439our_student_id,1440student_id,1441);14421443const src_project_id = s.get("project_id");1444if (!src_project_id) {1445return;1446}14471448// copy the files over from the student who did the peer grading1449await webapp_client.project_client.copy_path_between_projects({1450src_project_id,1451src_path,1452target_project_id: store.get("course_project_id"),1453target_path,1454overwrite_newer: false,1455delete_missing: false,1456timeout: COPY_TIMEOUT_MS / 1000,1457});14581459// write local file identifying the grader1460let name = store.get_student_name_extra(student_id);1461await this.write_text_file_to_course_project({1462path: target_path + `/GRADER - ${name.simple}.txt`,1463content: `The student who did the peer grading is named ${name.full}.`,1464});14651466// write local file identifying student being graded1467name = store.get_student_name_extra(our_student_id);1468await this.write_text_file_to_course_project({1469path: target_path + `/STUDENT - ${name.simple}.txt`,1470content: `This student is ${name.full}.`,1471});1472};14731474try {1475await map(peers, store.get_copy_parallel(), f);1476finish();1477} catch (err) {1478finish(err);1479}1480};14811482// This doesn't really stop it yet, since that's not supported by the backend.1483// It does stop the spinner and let the user try to restart the copy.1484stop_copying_assignment = (1485assignment_id: string,1486student_id: string,1487type: AssignmentCopyType,1488): void => {1489this.stop_copy(assignment_id, student_id, copy_type_to_last(type));1490};14911492open_assignment = (1493type: AssignmentCopyType,1494assignment_id: string,1495student_id: string,1496): void => {1497const { store, assignment, student } = this.course_actions.resolve({1498assignment_id,1499student_id,1500});1501if (assignment == null || student == null) return;1502const student_project_id = student.get("project_id");1503if (student_project_id == null) {1504this.course_actions.set_error(1505"open_assignment: student project not yet created",1506);1507return;1508}1509// Figure out what to open1510let path, proj;1511switch (type) {1512case "assigned": // where project was copied in the student's project.1513path = assignment.get("target_path");1514proj = student_project_id;1515break;1516case "collected": // where collected locally1517path = join(assignment.get("collect_path"), student.get("student_id")); // TODO: refactor1518proj = store.get("course_project_id");1519break;1520case "peer-assigned": // where peer-assigned (in student's project)1521proj = student_project_id;1522path = assignment.get("path") + "-peer-grade";1523break;1524case "peer-collected": // where collected peer-graded work (in our project)1525path =1526assignment.get("collect_path") +1527"-peer-grade/" +1528student.get("student_id");1529proj = store.get("course_project_id");1530break;1531case "graded": // where project returned1532path = assignment.get("graded_path"); // refactor1533proj = student_project_id;1534break;1535default:1536this.course_actions.set_error(1537`open_assignment -- unknown type: ${type}`,1538);1539}1540if (proj == null) {1541this.course_actions.set_error("no such project");1542return;1543}1544// Now open it1545redux.getProjectActions(proj).open_directory(path);1546};15471548private write_text_file_to_course_project = async (opts: {1549path: string;1550content: string;1551}): Promise<void> => {1552await webapp_client.project_client.write_text_file({1553project_id: this.get_store().get("course_project_id"),1554path: opts.path,1555content: opts.content,1556});1557};15581559// Update datastore with directory listing of non-hidden content of the assignment.1560// This also sets whether or not there is a STUDENT_SUBDIR directory.1561update_listing = async (assignment_id: string): Promise<void> => {1562const { store, assignment } = this.course_actions.resolve({1563assignment_id,1564});1565if (assignment == null) return;1566const project_id = store.get("course_project_id");1567const path = assignment.get("path");1568if (project_id == null || path == null) return;1569let listing;1570try {1571const { files } = await webapp_client.project_client.directory_listing({1572project_id,1573path,1574hidden: false,1575compute_server_id: 0, // TODO1576});1577listing = files;1578} catch (err) {1579// This might happen, e.g., if the assignment directory is deleted or user messes1580// with permissions...1581// In this case, just give up.1582return;1583}1584if (listing == null || this.course_actions.is_closed()) return;1585this.course_actions.set({1586listing,1587assignment_id,1588table: "assignments",1589});15901591let has_student_subdir: boolean = false;1592for (const entry of listing) {1593if (entry.isdir && entry.name == STUDENT_SUBDIR) {1594has_student_subdir = true;1595break;1596}1597}1598const nbgrader = await this.has_nbgrader_metadata(assignment_id);1599if (this.course_actions.is_closed()) return;1600this.course_actions.set({1601has_student_subdir,1602nbgrader,1603assignment_id,1604table: "assignments",1605});1606};16071608/* Scan all Jupyter notebooks in the top level of either the assignment directory or1609the student/1610subdirectory of it for cells with nbgrader metadata. If any are found, return1611true; otherwise, return false.1612*/1613private has_nbgrader_metadata = async (1614assignment_id: string,1615): Promise<boolean> => {1616return len(await this.nbgrader_instructor_ipynb_files(assignment_id)) > 0;1617};16181619// Read in the (stripped) contents of all nbgrader instructor ipynb1620// files for this assignment. These are:1621// - Every ipynb file in the assignment directory that has a cell that1622// contains nbgrader metadata (and isn't mangled).1623private nbgrader_instructor_ipynb_files = async (1624assignment_id: string,1625): Promise<{ [path: string]: string }> => {1626const { store, assignment } = this.course_actions.resolve({1627assignment_id,1628});1629if (assignment == null) {1630return {}; // nothing case.1631}1632const path = assignment.get("path");1633const project_id = store.get("course_project_id");1634let files;1635try {1636files = await redux1637.getProjectStore(project_id)1638.get_listings()1639.getListingDirectly(path);1640} catch (err) {1641// This happens, e.g., if the instructor moves the directory1642// that contains their version of the ipynb file.1643// See https://github.com/sagemathinc/cocalc/issues/55011644const 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})`;1645this.course_actions.set_error(error);1646throw err;1647}1648const result: { [path: string]: string } = {};16491650if (this.course_actions.is_closed()) return result;16511652const to_read = files1653.filter((entry) => !entry.isdir && endswith(entry.name, ".ipynb"))1654.map((entry) => entry.name);16551656const f: (file: string) => Promise<void> = async (file) => {1657if (this.course_actions.is_closed()) return;1658const fullpath = path != "" ? join(path, file) : file;1659try {1660const content = await jupyter_strip_notebook(project_id, fullpath);1661const { cells } = JSON.parse(content);1662for (const cell of cells) {1663if (cell.metadata.nbgrader) {1664result[file] = content;1665return;1666}1667}1668} catch (err) {1669return;1670}1671};16721673await map(to_read, 10, f);1674return result;1675};16761677// Run nbgrader for all students for which this assignment1678// has been collected at least once.1679run_nbgrader_for_all_students = async (1680assignment_id: string,1681ungraded_only?: boolean,1682): Promise<void> => {1683// console.log("run_nbgrader_for_all_students", assignment_id);1684const instructor_ipynb_files = await this.nbgrader_instructor_ipynb_files(1685assignment_id,1686);1687if (this.course_actions.is_closed()) return;1688const store = this.get_store();1689const nbgrader_scores = store.getIn([1690"assignments",1691assignment_id,1692"nbgrader_scores",1693]);1694const one_student: (student_id: string) => Promise<void> = async (1695student_id,1696) => {1697if (this.course_actions.is_closed()) return;1698if (!store.last_copied("collect", assignment_id, student_id, true)) {1699// Do not try to grade the assignment, since it wasn't1700// already successfully collected yet.1701return;1702}1703if (1704ungraded_only &&1705grading_state(student_id, nbgrader_scores) == "succeeded"1706) {1707// Do not try to grade assignment, if it has already been successfully graded.1708return;1709}1710await this.run_nbgrader_for_one_student(1711assignment_id,1712student_id,1713instructor_ipynb_files,1714true,1715);1716};1717try {1718this.nbgrader_set_is_running(assignment_id);1719await map(1720this.get_store().get_student_ids({ deleted: false }),1721this.get_store().get_nbgrader_parallel(),1722one_student,1723);1724this.course_actions.syncdb.commit();1725} finally {1726this.nbgrader_set_is_done(assignment_id);1727}1728};17291730set_nbgrader_scores_for_all_students = ({1731assignment_id,1732force,1733commit,1734}: {1735assignment_id: string;1736force?: boolean;1737commit?: boolean;1738}): void => {1739for (const student_id of this.get_store().get_student_ids({1740deleted: false,1741})) {1742this.set_grade_using_nbgrader_if_possible(1743assignment_id,1744student_id,1745false,1746force,1747);1748}1749if (commit) {1750this.course_actions.syncdb.commit();1751}1752};17531754set_nbgrader_scores_for_one_student = (1755assignment_id: string,1756student_id: string,1757scores: { [filename: string]: NotebookScores | string },1758nbgrader_score_ids:1759| { [filename: string]: string[] }1760| undefined = undefined,1761commit: boolean = true,1762): void => {1763const assignment_data = this.course_actions.get_one({1764table: "assignments",1765assignment_id,1766});1767if (assignment_data == null) return;1768const nbgrader_scores: {1769[student_id: string]: { [ipynb: string]: NotebookScores | string };1770} = assignment_data.nbgrader_scores || {};1771nbgrader_scores[student_id] = scores;1772this.course_actions.set(1773{1774table: "assignments",1775assignment_id,1776nbgrader_scores,1777...(nbgrader_score_ids != null ? { nbgrader_score_ids } : undefined),1778},1779commit,1780);1781this.set_grade_using_nbgrader_if_possible(1782assignment_id,1783student_id,1784commit,1785);1786};17871788set_specific_nbgrader_score = (1789assignment_id: string,1790student_id: string,1791filename: string,1792grade_id: string,1793score: number,1794commit: boolean = true,1795): void => {1796const { assignment } = this.course_actions.resolve({1797assignment_id,1798});1799if (assignment == null) {1800throw Error("no such assignment");1801}18021803const scores: any = assignment1804.getIn(["nbgrader_scores", student_id], Map())1805.toJS();1806let x: any = scores[filename];1807if (x == null) {1808x = scores[filename] = {};1809}1810let y = x[grade_id];1811if (y == null) {1812y = x[grade_id] = {};1813}1814y.score = score;1815if (y.points != null && y.score > y.points) {1816y.score = y.points;1817}1818if (y.score < 0) {1819y.score = 0;1820}1821this.set_nbgrader_scores_for_one_student(1822assignment_id,1823student_id,1824scores,1825undefined,1826commit,1827);18281829this.set_grade_using_nbgrader_if_possible(1830assignment_id,1831student_id,1832commit,1833);1834};18351836// Fill in manual grade if it is blank and there is an nbgrader grade1837// and all the manual nbgrader scores have been filled in.1838// Also, the filled in grade uses a specific format [number]/[total]1839// and if this is maintained and the nbgrader scores change, this1840// the manual grade is updated.1841set_grade_using_nbgrader_if_possible = (1842assignment_id: string,1843student_id: string,1844commit: boolean = true,1845force: boolean = false,1846): void => {1847// Check if nbgrader scores are all available.1848const store = this.get_store();1849const scores = store.get_nbgrader_scores(assignment_id, student_id);1850if (scores == null) {1851// no info -- maybe nbgrader not even run yet.1852return;1853}1854const { score, points, error, manual_needed } = get_nbgrader_score(scores);1855if (!force && (error || manual_needed)) {1856// more work must be done before we can use this.1857return;1858}18591860// Fill in the overall grade if either it is currently unset, blank,1861// or of the form [number]/[number].1862const grade = store.get_grade(assignment_id, student_id).trim();1863if (force || grade == "" || grade.match(/\d+\/\d+/g)) {1864this.set_grade(assignment_id, student_id, `${score}/${points}`, commit);1865}1866};18671868run_nbgrader_for_one_student = async (1869assignment_id: string,1870student_id: string,1871instructor_ipynb_files?: { [path: string]: string },1872commit: boolean = true,1873): Promise<void> => {1874// console.log("run_nbgrader_for_one_student", assignment_id, student_id);18751876const { store, assignment, student } = this.course_actions.resolve({1877assignment_id,1878student_id,1879});18801881if (1882student == null ||1883assignment == null ||1884!assignment.get("has_student_subdir")1885) {1886return; // nothing case.1887}18881889const nbgrader_grade_project: string | undefined = store.getIn([1890"settings",1891"nbgrader_grade_project",1892]);18931894const nbgrader_include_hidden_tests: boolean = !!store.getIn([1895"settings",1896"nbgrader_include_hidden_tests",1897]);18981899const course_project_id = store.get("course_project_id");1900const student_project_id = student.get("project_id");19011902let grade_project_id: string;1903let student_path: string;1904let stop_student_project = false;1905if (nbgrader_grade_project) {1906grade_project_id = nbgrader_grade_project;19071908// grade in the path where we collected their work.1909student_path = join(1910assignment.get("collect_path"),1911student.get("student_id"),1912);19131914this.course_actions.configuration.configure_nbgrader_grade_project(1915grade_project_id,1916);1917} else {1918if (student_project_id == null) {1919// This would happen if maybe instructor deletes student project at1920// the exact wrong time.1921// TODO: just create a new project for them?1922throw Error("student has no project, so can't run nbgrader");1923}1924grade_project_id = student_project_id;1925// grade right where student did their work.1926student_path = assignment.get("target_path");1927}19281929const where_grade =1930redux.getStore("projects").get_title(grade_project_id) ?? "a project";19311932const project_name = nbgrader_grade_project1933? `project ${trunc(where_grade, 40)}`1934: `${store.get_student_name(student_id)}'s project`;19351936if (instructor_ipynb_files == null) {1937instructor_ipynb_files = await this.nbgrader_instructor_ipynb_files(1938assignment_id,1939);1940if (this.course_actions.is_closed()) return;1941}1942if (len(instructor_ipynb_files) == 0) {1943/* console.log(1944"run_nbgrader_for_one_student",1945assignment_id,1946student_id,1947"done -- no ipynb files"1948); */1949return; // nothing to do1950}19511952const result: { [path: string]: any } = {};1953const scores: { [filename: string]: NotebookScores | string } = {};19541955const one_file: (file: string) => Promise<void> = async (file) => {1956const activity_id = this.course_actions.set_activity({1957desc: `Running nbgrader on ${store.get_student_name(1958student_id,1959)}'s "${file}" in '${trunc(where_grade, 40)}'`,1960});1961if (assignment == null || student == null) {1962// This won't happen, but it makes Typescript happy.1963return;1964}1965try {1966// fullpath = where their collected work is.1967const fullpath = join(1968assignment.get("collect_path"),1969student.get("student_id"),1970file,1971);1972const student_ipynb: string = await jupyter_strip_notebook(1973course_project_id,1974fullpath,1975);1976if (instructor_ipynb_files == null) throw Error("BUG");1977const instructor_ipynb: string = instructor_ipynb_files[file];1978if (this.course_actions.is_closed()) return;19791980const id = this.course_actions.set_activity({1981desc: `Ensuring ${project_name} is running`,1982});19831984try {1985const did_start = await start_project(grade_project_id, 60);1986// if *we* started the student project, we'll also stop it afterwards1987if (!nbgrader_grade_project) {1988stop_student_project = did_start;1989}1990} finally {1991this.course_actions.clear_activity(id);1992}19931994let ephemeralGradePath;1995try {1996if (1997grade_project_id != course_project_id &&1998grade_project_id != student_project_id1999) {2000ephemeralGradePath = true;2001// Make a fresh copy of the assignment files to the grade project.2002// This is necessary because grading the assignment may depend on2003// data files that are sent as part of the assignment. Also,2004// student's might have some code in text files next to the ipynb.2005await webapp_client.project_client.copy_path_between_projects({2006src_project_id: course_project_id,2007src_path: student_path,2008target_project_id: grade_project_id,2009target_path: student_path,2010overwrite_newer: true,2011delete_missing: true,2012backup: false,2013timeout: COPY_TIMEOUT_MS / 1000,2014});2015} else {2016ephemeralGradePath = false;2017}20182019const opts = {2020timeout_ms: store.getIn(2021["settings", "nbgrader_timeout_ms"],2022NBGRADER_TIMEOUT_MS,2023),2024cell_timeout_ms: store.getIn(2025["settings", "nbgrader_cell_timeout_ms"],2026NBGRADER_CELL_TIMEOUT_MS,2027),2028max_output: store.getIn(2029["settings", "nbgrader_max_output"],2030NBGRADER_MAX_OUTPUT,2031),2032max_output_per_cell: store.getIn(2033["settings", "nbgrader_max_output_per_cell"],2034NBGRADER_MAX_OUTPUT_PER_CELL,2035),2036student_ipynb,2037instructor_ipynb,2038path: student_path,2039project_id: grade_project_id,2040};2041/*console.log(2042student_id,2043file,2044"about to launch autograding with input ",2045opts2046);*/2047const r = await nbgrader(opts);2048/* console.log(student_id, "autograding finished successfully", {2049file,2050r,2051});*/2052result[file] = r;2053} finally {2054if (ephemeralGradePath) {2055await webapp_client.project_client.exec({2056project_id: grade_project_id,2057command: "rm",2058args: ["-rf", student_path],2059});2060}2061}20622063if (!nbgrader_grade_project && stop_student_project) {2064const idstop = this.course_actions.set_activity({2065desc: `Stopping project ${project_name} after grading.`,2066});2067try {2068await stop_project(grade_project_id, 60);2069} finally {2070this.course_actions.clear_activity(idstop);2071}2072}2073} catch (err) {2074// console.log("nbgrader failed", { student_id, file, err });2075scores[file] = `${err}`;2076} finally {2077this.course_actions.clear_activity(activity_id);2078}2079};20802081// NOTE: we *could* run multiple files in parallel, but that causes2082// trouble for very little benefit. It's better to run across all students in parallel,2083// and the trouble is just that running lots of code in the same project can confuse2084// the backend api and use extra memory (which is unfair to students being graded, e.g.,2085// if their project has 1GB of RAM and we run 3 notebooks at once, they get "gypped").2086try {2087this.nbgrader_set_is_running(assignment_id, student_id);20882089for (const file in instructor_ipynb_files) {2090await one_file(file);2091}2092} finally {2093this.nbgrader_set_is_done(assignment_id, student_id);2094}2095/* console.log("ran nbgrader for all files for a student", {2096student_id,2097result2098}); */2099// Save any previous nbgrader scores for this student, so we can2100// preserve any manually entered scores, rather than overwrite them.2101const prev_scores = store.get_nbgrader_scores(assignment_id, student_id);21022103const nbgrader_score_ids: { [filename: string]: string[] } = {};21042105for (const filename in result) {2106const r = result[filename];2107if (r == null) continue;2108if (r.output == null) continue;2109if (r.ids != null) {2110nbgrader_score_ids[filename] = r.ids;2111}21122113// Depending on instructor options, write the graded version of2114// the notebook to disk, so the student can see why their grade2115// is what it is:2116const notebook = JSON.parse(r.output);2117scores[filename] = extract_auto_scores(notebook);2118if (2119prev_scores != null &&2120prev_scores[filename] != null &&2121typeof prev_scores[filename] != "string"2122) {2123// preserve any manual scores. cast since for some reason the typeof above isn't enough.2124for (const id in prev_scores[filename] as object) {2125const x = prev_scores[filename][id];2126if (x.manual && x.score && scores[filename][id] != null) {2127scores[filename][id].score = x.score;2128}2129}2130}21312132if (!nbgrader_include_hidden_tests) {2133// IMPORTANT: this *must* happen after extracting scores above!2134// Otherwise students get perfect grades.2135ipynb_clear_hidden_tests(notebook);2136}21372138await this.write_autograded_notebook(2139assignment,2140student_id,2141filename,2142JSON.stringify(notebook, undefined, 2),2143);2144}21452146this.set_nbgrader_scores_for_one_student(2147assignment_id,2148student_id,2149scores,2150nbgrader_score_ids,2151commit,2152);2153};21542155autograded_path = (2156assignment: AssignmentRecord,2157student_id: string,2158filename: string,2159): string => {2160return autograded_filename(2161join(assignment.get("collect_path"), student_id, filename),2162);2163};21642165private write_autograded_notebook = async (2166assignment: AssignmentRecord,2167student_id: string,2168filename: string,2169content: string,2170): Promise<void> => {2171const path = this.autograded_path(assignment, student_id, filename);2172await this.write_text_file_to_course_project({ path, content });2173};21742175open_file_in_collected_assignment = async (2176assignment_id: string,2177student_id: string,2178file: string,2179): Promise<void> => {2180const { assignment, student, store } = this.course_actions.resolve({2181assignment_id,2182student_id,2183});2184if (assignment == null || student == null) {2185throw Error("no such student or assignment");2186}2187const course_project_id = store.get("course_project_id");2188const fullpath = join(2189assignment.get("collect_path"),2190student.get("student_id"),2191file,2192);21932194await redux2195.getProjectActions(course_project_id)2196.open_file({ path: fullpath, foreground: true });2197};21982199private nbgrader_set_is_running = (2200assignment_id: string,2201student_id?: string,2202): void => {2203const store = this.get_store();2204let nbgrader_run_info: NBgraderRunInfo = store.get(2205"nbgrader_run_info",2206Map(),2207);2208const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;2209nbgrader_run_info = nbgrader_run_info.set(key, webapp_client.server_time());2210this.course_actions.setState({ nbgrader_run_info });2211};22122213private nbgrader_set_is_done = (2214assignment_id: string,2215student_id?: string,2216): void => {2217const store = this.get_store();2218let nbgrader_run_info: NBgraderRunInfo = store.get(2219"nbgrader_run_info",2220Map<string, number>(),2221);2222const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;2223nbgrader_run_info = nbgrader_run_info.delete(key);2224this.course_actions.setState({ nbgrader_run_info });2225};22262227export_file_use_times = async (2228assignment_id: string,2229json_filename: string,2230): Promise<void> => {2231// Get the path of the assignment2232const { assignment, store } = this.course_actions.resolve({2233assignment_id,2234});2235if (assignment == null) {2236throw Error("no such assignment");2237}2238const src_path = this.assignment_src_path(assignment);2239const target_path = assignment.get("path");2240await export_student_file_use_times(2241store.get("course_project_id"),2242src_path,2243target_path,2244store.get("students"),2245json_filename,2246store.get_student_name.bind(store),2247);2248};22492250export_collected = async (assignment_id: string): Promise<void> => {2251const set_activity = this.course_actions.set_activity.bind(2252this.course_actions,2253);2254const id = set_activity({2255desc: "Exporting collected files...",2256});2257try {2258const { assignment, store } = this.course_actions.resolve({2259assignment_id,2260});2261if (assignment == null) return;2262const students = store.get("students");2263const src_path = this.assignment_src_path(assignment);2264const collect_path = assignment.get("collect_path");2265const i = store.get("course_filename").lastIndexOf(".");2266const base_export_path =2267store.get("course_filename").slice(0, i) + "-export";2268const export_path = join(base_export_path, src_path);22692270const student_name = function (student_id: string): string {2271const v = split(store.get_student_name(student_id));2272return v.join("_");2273};22742275const activity = function (s: string): void {2276set_activity({2277id,2278desc: "Exporting collected files... " + s,2279});2280};22812282const project_id = store.get("course_project_id");22832284await export_assignment(2285project_id,2286collect_path,2287export_path,2288students,2289student_name,2290activity,2291);22922293redux.getProjectActions(project_id).open_directory(base_export_path);2294} catch (err) {2295this.course_actions.set_error(2296`Error exporting collected student files -- ${err}`,2297);2298} finally {2299set_activity({ id });2300}2301};2302}230323042305