Path: blob/master/src/packages/frontend/course/assignments/actions.ts
5979 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 { mapParallelLimit } from "@cocalc/util/async-utils";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,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,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});855const opts = {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,864};865await webapp_client.project_client.copy_path_between_projects(opts);866await this.course_actions.compute.setComputeServerAssociations({867student_id,868src_path: opts.src_path,869target_project_id: opts.target_project_id,870target_path: opts.target_path,871unit_id: assignment_id,872});873874// successful finish875finish();876} catch (err) {877// error somewhere along the way878finish(err);879}880};881882private assignment_src_path = (assignment): string => {883let path = assignment.get("path");884if (assignment.get("has_student_subdir")) {885path = join(path, STUDENT_SUBDIR);886}887return path;888};889890// this is part of the assignment disribution, should be done only *once*, not for every student891private copy_assignment_create_due_date_file = async (892assignment_id: string,893): Promise<void> => {894const { assignment } = this.course_actions.resolve({895assignment_id,896});897if (!assignment) return;898// write the due date to a file899const src_path = this.assignment_src_path(assignment);900const due_id = this.course_actions.set_activity({901desc: `Creating ${DUE_DATE_FILENAME} file...`,902});903const content = this.dueDateFileContent(assignment_id);904const path = join(src_path, DUE_DATE_FILENAME);905906try {907await this.write_text_file_to_course_project({908path,909content,910});911} catch (err) {912throw Error(913`Problem writing ${DUE_DATE_FILENAME} file ('${err}'). Try again...`,914);915} finally {916this.course_actions.clear_activity(due_id);917}918};919920private dueDateFileContent = (assignment_id) => {921const due_date = this.get_store()?.get_due_date(assignment_id);922if (due_date) {923return `This assignment is due\n\n ${due_date.toLocaleString()}`;924} else {925return "No due date has been set.";926}927};928929copy_assignment = async (930type: AssignmentCopyType,931assignment_id: string,932student_id: string,933): Promise<void> => {934// type = assigned, collected, graded, peer-assigned, peer-collected935switch (type) {936case "assigned":937// make sure listing is up to date, since it sets "has_student_subdir",938// which impacts the distribute semantics.939await this.update_listing(assignment_id);940await this.copy_assignment_to_student(assignment_id, student_id, {941create_due_date_file: true,942});943return;944case "collected":945await this.copy_assignment_from_student(assignment_id, student_id);946return;947case "graded":948await this.return_assignment_to_student(assignment_id, student_id);949return;950case "peer-assigned":951await this.peer_copy_to_student(assignment_id, student_id);952return;953case "peer-collected":954await this.peer_collect_from_student(assignment_id, student_id);955return;956default:957this.course_actions.set_error(958`copy_assignment -- unknown type: ${type}`,959);960return;961}962};963964// Copy the given assignment to all non-deleted students, doing several copies in parallel at once.965copy_assignment_to_all_students = async (966assignment_id: string,967new_only: boolean,968overwrite: boolean,969): Promise<void> => {970const desc = `Copying assignments to all students ${971new_only ? "who have not already received it" : ""972}`;973const short_desc = "copy to student";974await this.update_listing(assignment_id); // make sure this is up to date975if (this.course_actions.is_closed()) return;976await this.copy_assignment_create_due_date_file(assignment_id);977if (this.course_actions.is_closed()) return;978// by default, doesn't create the due file979await this.assignment_action_all_students({980assignment_id,981new_only,982action: this.copy_assignment_to_student,983step: "assignment",984desc,985short_desc,986overwrite,987});988};989990// Copy the given assignment to from all non-deleted students, doing several copies in parallel at once.991copy_assignment_from_all_students = async (992assignment_id: string,993new_only: boolean,994): Promise<void> => {995let desc = "Copying assignment from all students";996if (new_only) {997desc += " from whom we have not already copied it";998}999const short_desc = "copy from student";1000await this.assignment_action_all_students({1001assignment_id,1002new_only,1003action: this.copy_assignment_from_student,1004step: "collect",1005desc,1006short_desc,1007});1008};10091010private start_all_for_peer_grading = async (): Promise<void> => {1011// On cocalc.com, if the student projects get started specifically1012// for the purposes of copying files to/from them, then they stop1013// around a minute later. This is very bad for peer grading, since1014// so much copying occurs, and we end up with conflicts between1015// projects starting to peer grade, then stop, then needing to be1016// started again all at once. We thus request that they all start,1017// wait a few seconds for that "reason" for them to be running to1018// take effect, and then do the copy. This way the projects aren't1019// automatically stopped after the copies happen.1020const id = this.course_actions.set_activity({1021desc: "Warming up all student projects for peer grading...",1022});1023this.course_actions.student_projects.action_all_student_projects("start");1024// We request to start all projects simultaneously, and the system1025// will start doing that. I think it's not so much important that1026// the projects are actually running, but that they were started1027// before the copy operations started.1028await delay(5 * 1000);1029this.course_actions.clear_activity(id);1030};10311032async peer_copy_to_all_students(1033assignment_id: string,1034new_only: boolean,1035): Promise<void> {1036let desc = "Copying assignments for peer grading to all students ";1037if (new_only) {1038desc += " who have not already received their copy";1039}1040const short_desc = "copy to student for peer grading";1041// CRITICAL: be sure to run this update once before doing the1042// assignment. Otherwise, since assignment runs more than once1043// in parallel, two will launch at about the same time and1044// the *condition* to know if it is done depends on the store,1045// which defers when it gets updated. Anyway, this line is critical:1046try {1047this.update_peer_assignment(assignment_id);1048} catch (err) {1049this.course_actions.set_error(`${short_desc} -- ${err}`);1050return;1051}1052await this.start_all_for_peer_grading();1053// OK, now do the assignment... in parallel.1054await this.assignment_action_all_students({1055assignment_id,1056new_only,1057action: this.peer_copy_to_student,1058step: "peer_assignment",1059desc,1060short_desc,1061});1062}10631064async peer_collect_from_all_students(1065assignment_id: string,1066new_only: boolean,1067): Promise<void> {1068let desc = "Copying peer graded assignments from all students";1069if (new_only) {1070desc += " from whom we have not already copied it";1071}1072const short_desc = "copy peer grading from students";1073await this.start_all_for_peer_grading();1074await this.assignment_action_all_students({1075assignment_id,1076new_only,1077action: this.peer_collect_from_student,1078step: "peer_collect",1079desc,1080short_desc,1081});1082await this.peerParseStudentGrading(assignment_id);1083}10841085private peerParseStudentGrading = async (assignment_id: string) => {1086// For each student do the following:1087// If they already have a recorded grade, do nothing further.1088// If they do not have a recorded grade, load all of the1089// PEER_GRADING_GUIDE_FILENAME files that were collected1090// from the students, then create a grade from that (if possible), along1091// with a comment that explains how that grade was obtained, without1092// saying which student did what.1093const { store, assignment } = this.course_actions.resolve({1094assignment_id,1095});1096if (assignment == null) {1097throw Error("no such assignment");1098}1099const id = this.course_actions.set_activity({1100desc: "Parsing peer grading",1101});1102const allGrades = assignment.get("grades", Map()).toJS() as {1103[student_id: string]: string;1104};1105const allComments = assignment.get("comments", Map()).toJS() as {1106[student_id: string]: string;1107};1108// compute missing grades1109for (const student_id of store.get_student_ids()) {1110if (allGrades[student_id]) {1111// a grade is already set1112continue;1113}1114// attempt to compute a grade1115const peer_student_ids: string[] = store.get_peers_that_graded_student(1116assignment_id,1117student_id,1118);1119const course_project_id = store.get("course_project_id");1120const grades: number[] = [];1121let comments: string[] = [];1122const student_name = store.get_student_name(student_id);1123this.course_actions.set_activity({1124id,1125desc: `Parsing peer grading of ${student_name}`,1126});1127await mapParallelLimit(peer_student_ids, async (peer_student_id) => {1128const path = join(1129`${assignment.get("collect_path")}-peer-grade`,1130student_id,1131peer_student_id,1132PEER_GRADING_GUIDE_FILENAME,1133);1134try {1135const contents = await webapp_client.project_client.read_text_file({1136project_id: course_project_id,1137path,1138});1139const i = contents.lastIndexOf(PEER_GRADING_GUIDELINES_GRADE_MARKER);1140if (i == -1) {1141return;1142}1143let j = contents.lastIndexOf(PEER_GRADING_GUIDELINES_COMMENT_MARKER);1144if (j == -1) {1145j = contents.length;1146}1147const grade = parseFloat(1148contents1149.slice(i + PEER_GRADING_GUIDELINES_GRADE_MARKER.length, j)1150.trim(),1151);1152if (!isFinite(grade) && isNaN(grade)) {1153return;1154}1155const comment = contents.slice(1156j + PEER_GRADING_GUIDELINES_COMMENT_MARKER.length,1157);1158grades.push(grade);1159comments.push(comment);1160} catch (err) {1161// grade not available for some reason1162console.warn("issue reading peer grading file", {1163path,1164err,1165student_name,1166});1167}1168});1169if (grades.length > 0) {1170const grade = grades.reduce((a, b) => a + b) / grades.length;1171allGrades[student_id] = `${grade}`;1172if (!allComments[student_id]) {1173const studentComments = comments1174.filter((x) => x.trim())1175.map((x) => `- ${x.trim()}`)1176.join("\n\n");1177allComments[student_id] = `Grades: ${grades.join(", ")}\n\n${1178studentComments ? "Student Comments:\n" + studentComments : ""1179}`;1180}1181}1182}1183// set them in the course data1184this.course_actions.set(1185{1186table: "assignments",1187assignment_id,1188grades: allGrades,1189comments: allComments,1190},1191true,1192);1193this.course_actions.clear_activity(id);1194};11951196private assignment_action_all_students = async ({1197assignment_id,1198new_only,1199old_only,1200action,1201step,1202desc,1203short_desc,1204overwrite,1205}: {1206assignment_id: string;1207// only do the action when it hasn't been done already1208new_only?: boolean;1209// only do the action when it HAS been done already1210old_only?: boolean;1211action: (1212assignment_id: string,1213student_id: string,1214opts: any,1215) => Promise<void>;1216step;1217desc;1218short_desc: string;1219overwrite?: boolean;1220}): Promise<void> => {1221if (new_only && old_only) {1222// no matter what, this means the empty set, so nothing to do.1223// Of course no code shouild actually call this.1224return;1225}1226const id = this.course_actions.set_activity({ desc });1227const finish = (err) => {1228this.course_actions.clear_activity(id);1229err = `${short_desc}: ${err}`;1230this.course_actions.set_error(err);1231};1232const { store, assignment } = this.course_actions.resolve({1233assignment_id,1234finish,1235});1236if (!assignment) return;1237let errors = "";1238const peer: boolean = assignment.getIn(["peer_grade", "enabled"], false);1239const prev_step =1240step == "assignment" ? undefined : previous_step(step, peer);1241const f = async (student_id: string): Promise<void> => {1242if (this.course_actions.is_closed()) return;1243const store = this.get_store();1244if (1245prev_step != null &&1246!store.last_copied(prev_step, assignment_id, student_id, true)1247) {1248return;1249}1250const alreadyCopied = !!store.last_copied(1251step,1252assignment_id,1253student_id,1254true,1255);1256if (new_only && alreadyCopied) {1257// only for the ones that haven't already been copied1258return;1259}1260if (old_only && !alreadyCopied) {1261// only for the ones that *HAVE* already been copied.1262return;1263}1264try {1265await action(assignment_id, student_id, { overwrite });1266} catch (err) {1267errors += `\n ${err}`;1268}1269};12701271await map(1272store.get_student_ids({ deleted: false }),1273store.get_copy_parallel(),1274f,1275);12761277if (errors) {1278finish(errors);1279} else {1280this.course_actions.clear_activity(id);1281}1282};12831284// Copy the collected folders from some students to the given student for peer grading.1285// Assumes folder is non-empty1286private peer_copy_to_student = async (1287assignment_id: string,1288student_id: string,1289): Promise<void> => {1290if (this.start_copy(assignment_id, student_id, "last_peer_assignment")) {1291return;1292}1293const id = this.course_actions.set_activity({1294desc: "Copying peer grading to a student",1295});1296const finish = (err?) => {1297this.course_actions.clear_activity(id);1298this.finish_copy(assignment_id, student_id, "last_peer_assignment", err);1299if (err) {1300this.course_actions.set_error(`copy peer-grading to student: ${err}`);1301}1302};1303const { store, student, assignment } = this.course_actions.resolve({1304assignment_id,1305student_id,1306finish,1307});1308if (!student || !assignment) return;13091310const student_name = store.get_student_name(student_id);1311this.course_actions.set_activity({1312id,1313desc: `Copying peer grading to ${student_name}`,1314});13151316let peer_map;1317try {1318// synchronous, but could fail, e.g., not enough students1319peer_map = this.update_peer_assignment(assignment_id);1320} catch (err) {1321this.course_actions.set_error(`peer copy to student: ${err}`);1322finish();1323return;1324}13251326if (peer_map == null) {1327finish();1328return;1329}13301331const peers = peer_map[student.get("student_id")];1332if (peers == null) {1333// empty peer assignment for this student (maybe student added after1334// peer assignment already created?)1335finish();1336return;1337}13381339const student_project_id = student.get("project_id");1340if (!student_project_id) {1341finish();1342return;1343}13441345let guidelines: string = assignment.getIn(1346["peer_grade", "guidelines"],1347"Please grade this assignment.",1348);1349const due_date = assignment.getIn(["peer_grade", "due_date"]);1350if (due_date != null) {1351guidelines =1352`GRADING IS DUE ${new Date(due_date).toLocaleString()} \n\n ` +1353guidelines;1354}13551356const target_base_path = assignment.get("path") + "-peer-grade";1357const f = async (peer_student_id: string) => {1358if (this.course_actions.is_closed()) {1359return;1360}1361const src_path = join(assignment.get("collect_path"), peer_student_id);1362// write instructions file for the student, where they enter the grade,1363// and also it tells them what to do.1364await this.write_text_file_to_course_project({1365path: join(src_path, PEER_GRADING_GUIDE_FILENAME),1366content: guidelines,1367});1368const target_path = join(target_base_path, peer_student_id);1369// In the copy below, we exclude the student's name so that1370// peer grading is anonymous; also, remove original1371// due date to avoid confusion.1372// copy the files to be peer graded into place for this student1373await webapp_client.project_client.copy_path_between_projects({1374src_project_id: store.get("course_project_id"),1375src_path,1376target_project_id: student_project_id,1377target_path,1378overwrite_newer: false,1379delete_missing: false,1380exclude: ["*STUDENT*.txt", "*" + DUE_DATE_FILENAME + "*"],1381timeout: COPY_TIMEOUT_MS,1382});1383};13841385try {1386// now copy actual stuff to grade1387await map(peers, store.get_copy_parallel(), f);1388finish();1389} catch (err) {1390finish(err);1391return;1392}1393};13941395// Collect all the peer graading of the given student (not the work the student did, but1396// the grading about the student!).1397private peer_collect_from_student = async (1398assignment_id: string,1399student_id: string,1400): Promise<void> => {1401if (this.start_copy(assignment_id, student_id, "last_peer_collect")) {1402return;1403}1404const id = this.course_actions.set_activity({1405desc: "Collecting peer grading of a student",1406});1407const finish = (err?) => {1408this.course_actions.clear_activity(id);1409this.finish_copy(assignment_id, student_id, "last_peer_collect", err);1410if (err) {1411this.course_actions.set_error(1412`collecting peer-grading of a student: ${err}`,1413);1414}1415};14161417const { store, student, assignment } = this.course_actions.resolve({1418student_id,1419assignment_id,1420finish,1421});1422if (!student || !assignment) return;14231424const student_name = store.get_student_name(student_id);1425this.course_actions.set_activity({1426id,1427desc: `Collecting peer grading of ${student_name}`,1428});14291430// list of student_id of students that graded this student (may be empty)1431const peers: string[] = store.get_peers_that_graded_student(1432assignment_id,1433student_id,1434);14351436const our_student_id = student.get("student_id");14371438const f = async (student_id: string): Promise<void> => {1439const s = store.get_student(student_id);1440// ignore deleted or non-existent students1441if (s == null || s.get("deleted")) return;14421443const path = assignment.get("path");1444const src_path = join(`${path}-peer-grade`, our_student_id);1445const target_path = join(1446`${assignment.get("collect_path")}-peer-grade`,1447our_student_id,1448student_id,1449);14501451const src_project_id = s.get("project_id");1452if (!src_project_id) {1453return;1454}14551456// copy the files over from the student who did the peer grading1457await webapp_client.project_client.copy_path_between_projects({1458src_project_id,1459src_path,1460target_project_id: store.get("course_project_id"),1461target_path,1462overwrite_newer: false,1463delete_missing: false,1464timeout: COPY_TIMEOUT_MS,1465});14661467// write local file identifying the grader1468let name = store.get_student_name_extra(student_id);1469await this.write_text_file_to_course_project({1470path: target_path + `/GRADER - ${name.simple}.txt`,1471content: `The student who did the peer grading is named ${name.full}.`,1472});14731474// write local file identifying student being graded1475name = store.get_student_name_extra(our_student_id);1476await this.write_text_file_to_course_project({1477path: target_path + `/STUDENT - ${name.simple}.txt`,1478content: `This student is ${name.full}.`,1479});1480};14811482try {1483await map(peers, store.get_copy_parallel(), f);1484finish();1485} catch (err) {1486finish(err);1487}1488};14891490// This doesn't really stop it yet, since that's not supported by the backend.1491// It does stop the spinner and let the user try to restart the copy.1492stop_copying_assignment = (1493assignment_id: string,1494student_id: string,1495type: AssignmentCopyType,1496): void => {1497this.stop_copy(assignment_id, student_id, copy_type_to_last(type));1498};14991500open_assignment = (1501type: AssignmentCopyType,1502assignment_id: string,1503student_id: string,1504): void => {1505const { store, assignment, student } = this.course_actions.resolve({1506assignment_id,1507student_id,1508});1509if (assignment == null || student == null) return;1510const student_project_id = student.get("project_id");1511if (student_project_id == null) {1512this.course_actions.set_error(1513"open_assignment: student project not yet created",1514);1515return;1516}1517// Figure out what to open1518let path, proj;1519switch (type) {1520case "assigned": // where project was copied in the student's project.1521path = assignment.get("target_path");1522proj = student_project_id;1523break;1524case "collected": // where collected locally1525path = join(assignment.get("collect_path"), student.get("student_id")); // TODO: refactor1526proj = store.get("course_project_id");1527break;1528case "peer-assigned": // where peer-assigned (in student's project)1529proj = student_project_id;1530path = assignment.get("path") + "-peer-grade";1531break;1532case "peer-collected": // where collected peer-graded work (in our project)1533path =1534assignment.get("collect_path") +1535"-peer-grade/" +1536student.get("student_id");1537proj = store.get("course_project_id");1538break;1539case "graded": // where project returned1540path = assignment.get("graded_path"); // refactor1541proj = student_project_id;1542break;1543default:1544this.course_actions.set_error(1545`open_assignment -- unknown type: ${type}`,1546);1547}1548if (proj == null) {1549this.course_actions.set_error("no such project");1550return;1551}1552// Now open it1553redux.getProjectActions(proj).open_directory(path);1554};15551556private write_text_file_to_course_project = async (opts: {1557path: string;1558content: string;1559}): Promise<void> => {1560await webapp_client.project_client.write_text_file({1561project_id: this.get_store().get("course_project_id"),1562path: opts.path,1563content: opts.content,1564});1565};15661567// Update datastore with directory listing of non-hidden content of the assignment.1568// This also sets whether or not there is a STUDENT_SUBDIR directory.1569update_listing = async (assignment_id: string): Promise<void> => {1570const { store, assignment } = this.course_actions.resolve({1571assignment_id,1572});1573if (assignment == null) return;1574const project_id = store.get("course_project_id");1575const path = assignment.get("path");1576if (project_id == null || path == null) return;1577let listing;1578try {1579const { files } = await webapp_client.project_client.directory_listing({1580project_id,1581path,1582hidden: false,1583compute_server_id: 0, // TODO1584});1585listing = files;1586} catch (err) {1587// This might happen, e.g., if the assignment directory is deleted or user messes1588// with permissions...1589// In this case, just give up.1590return;1591}1592if (listing == null || this.course_actions.is_closed()) return;1593this.course_actions.set({1594listing,1595assignment_id,1596table: "assignments",1597});15981599let has_student_subdir: boolean = false;1600for (const entry of listing) {1601if (entry.isdir && entry.name == STUDENT_SUBDIR) {1602has_student_subdir = true;1603break;1604}1605}1606const nbgrader = await this.has_nbgrader_metadata(assignment_id);1607if (this.course_actions.is_closed()) return;1608this.course_actions.set({1609has_student_subdir,1610nbgrader,1611assignment_id,1612table: "assignments",1613});1614};16151616/* Scan all Jupyter notebooks in the top level of either the assignment directory or1617the student/1618subdirectory of it for cells with nbgrader metadata. If any are found, return1619true; otherwise, return false.1620*/1621private has_nbgrader_metadata = async (1622assignment_id: string,1623): Promise<boolean> => {1624return len(await this.nbgrader_instructor_ipynb_files(assignment_id)) > 0;1625};16261627// Read in the (stripped) contents of all nbgrader instructor ipynb1628// files for this assignment. These are:1629// - Every ipynb file in the assignment directory that has a cell that1630// contains nbgrader metadata (and isn't mangled).1631private nbgrader_instructor_ipynb_files = async (1632assignment_id: string,1633): Promise<{ [path: string]: string }> => {1634const { store, assignment } = this.course_actions.resolve({1635assignment_id,1636});1637if (assignment == null) {1638return {}; // nothing case.1639}1640const path = assignment.get("path");1641const project_id = store.get("course_project_id");1642let files;1643try {1644files = await redux1645.getProjectStore(project_id)1646.get_listings()1647.getListingDirectly(path);1648} catch (err) {1649// This happens, e.g., if the instructor moves the directory1650// that contains their version of the ipynb file.1651// See https://github.com/sagemathinc/cocalc/issues/55011652const 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})`;1653this.course_actions.set_error(error);1654throw err;1655}1656const result: { [path: string]: string } = {};16571658if (this.course_actions.is_closed()) return result;16591660const to_read = files1661.filter((entry) => !entry.isdir && endswith(entry.name, ".ipynb"))1662.map((entry) => entry.name);16631664const f: (file: string) => Promise<void> = async (file) => {1665if (this.course_actions.is_closed()) return;1666const fullpath = path != "" ? join(path, file) : file;1667try {1668const content = await jupyter_strip_notebook(project_id, fullpath);1669const { cells } = JSON.parse(content);1670for (const cell of cells) {1671if (cell.metadata.nbgrader) {1672result[file] = content;1673return;1674}1675}1676} catch (err) {1677return;1678}1679};16801681await map(to_read, 10, f);1682return result;1683};16841685// Run nbgrader for all students for which this assignment1686// has been collected at least once.1687run_nbgrader_for_all_students = async (1688assignment_id: string,1689ungraded_only?: boolean,1690): Promise<void> => {1691// console.log("run_nbgrader_for_all_students", assignment_id);1692const instructor_ipynb_files =1693await this.nbgrader_instructor_ipynb_files(assignment_id);1694if (this.course_actions.is_closed()) return;1695const store = this.get_store();1696const nbgrader_scores = store.getIn([1697"assignments",1698assignment_id,1699"nbgrader_scores",1700]);1701const one_student: (student_id: string) => Promise<void> = async (1702student_id,1703) => {1704if (this.course_actions.is_closed()) return;1705if (!store.last_copied("collect", assignment_id, student_id, true)) {1706// Do not try to grade the assignment, since it wasn't1707// already successfully collected yet.1708return;1709}1710if (1711ungraded_only &&1712grading_state(student_id, nbgrader_scores) == "succeeded"1713) {1714// Do not try to grade assignment, if it has already been successfully graded.1715return;1716}1717await this.run_nbgrader_for_one_student(1718assignment_id,1719student_id,1720instructor_ipynb_files,1721true,1722);1723};1724try {1725this.nbgrader_set_is_running(assignment_id);1726await map(1727this.get_store().get_student_ids({ deleted: false }),1728this.get_store().get_nbgrader_parallel(),1729one_student,1730);1731this.course_actions.syncdb.commit();1732} finally {1733this.nbgrader_set_is_done(assignment_id);1734}1735};17361737set_nbgrader_scores_for_all_students = ({1738assignment_id,1739force,1740commit,1741}: {1742assignment_id: string;1743force?: boolean;1744commit?: boolean;1745}): void => {1746for (const student_id of this.get_store().get_student_ids({1747deleted: false,1748})) {1749this.set_grade_using_nbgrader_if_possible(1750assignment_id,1751student_id,1752false,1753force,1754);1755}1756if (commit) {1757this.course_actions.syncdb.commit();1758}1759};17601761set_nbgrader_scores_for_one_student = (1762assignment_id: string,1763student_id: string,1764scores: { [filename: string]: NotebookScores | string },1765nbgrader_score_ids:1766| { [filename: string]: string[] }1767| undefined = undefined,1768commit: boolean = true,1769): void => {1770const assignment_data = this.course_actions.get_one({1771table: "assignments",1772assignment_id,1773});1774if (assignment_data == null) return;1775const nbgrader_scores: {1776[student_id: string]: { [ipynb: string]: NotebookScores | string };1777} = assignment_data.nbgrader_scores || {};1778nbgrader_scores[student_id] = scores;1779this.course_actions.set(1780{1781table: "assignments",1782assignment_id,1783nbgrader_scores,1784...(nbgrader_score_ids != null ? { nbgrader_score_ids } : undefined),1785},1786commit,1787);1788this.set_grade_using_nbgrader_if_possible(1789assignment_id,1790student_id,1791commit,1792);1793};17941795set_specific_nbgrader_score = (1796assignment_id: string,1797student_id: string,1798filename: string,1799grade_id: string,1800score: number,1801commit: boolean = true,1802): void => {1803const { assignment } = this.course_actions.resolve({1804assignment_id,1805});1806if (assignment == null) {1807throw Error("no such assignment");1808}18091810const scores: any = assignment1811.getIn(["nbgrader_scores", student_id], Map())1812.toJS();1813let x: any = scores[filename];1814if (x == null) {1815x = scores[filename] = {};1816}1817let y = x[grade_id];1818if (y == null) {1819y = x[grade_id] = {};1820}1821y.score = score;1822if (y.points != null && y.score > y.points) {1823y.score = y.points;1824}1825if (y.score < 0) {1826y.score = 0;1827}1828this.set_nbgrader_scores_for_one_student(1829assignment_id,1830student_id,1831scores,1832undefined,1833commit,1834);18351836this.set_grade_using_nbgrader_if_possible(1837assignment_id,1838student_id,1839commit,1840);1841};18421843// Fill in manual grade if it is blank and there is an nbgrader grade1844// and all the manual nbgrader scores have been filled in.1845// Also, the filled in grade uses a specific format [number]/[total]1846// and if this is maintained and the nbgrader scores change, this1847// the manual grade is updated.1848set_grade_using_nbgrader_if_possible = (1849assignment_id: string,1850student_id: string,1851commit: boolean = true,1852force: boolean = false,1853): void => {1854// Check if nbgrader scores are all available.1855const store = this.get_store();1856const scores = store.get_nbgrader_scores(assignment_id, student_id);1857if (scores == null) {1858// no info -- maybe nbgrader not even run yet.1859return;1860}1861const { score, points, error, manual_needed } = get_nbgrader_score(scores);1862if (!force && (error || manual_needed)) {1863// more work must be done before we can use this.1864return;1865}18661867// Fill in the overall grade if either it is currently unset, blank,1868// or of the form [number]/[number].1869const grade = store.get_grade(assignment_id, student_id).trim();1870if (force || grade == "" || grade.match(/\d+\/\d+/g)) {1871this.set_grade(assignment_id, student_id, `${score}/${points}`, commit);1872}1873};18741875run_nbgrader_for_one_student = async (1876assignment_id: string,1877student_id: string,1878instructor_ipynb_files?: { [path: string]: string },1879commit: boolean = true,1880): Promise<void> => {1881// console.log("run_nbgrader_for_one_student", assignment_id, student_id);18821883const { store, assignment, student } = this.course_actions.resolve({1884assignment_id,1885student_id,1886});18871888if (1889student == null ||1890assignment == null ||1891!assignment.get("has_student_subdir")1892) {1893return; // nothing case.1894}18951896const nbgrader_grade_project: string | undefined = store.getIn([1897"settings",1898"nbgrader_grade_project",1899]);19001901const nbgrader_include_hidden_tests: boolean = !!store.getIn([1902"settings",1903"nbgrader_include_hidden_tests",1904]);19051906const course_project_id = store.get("course_project_id");1907const student_project_id = student.get("project_id");19081909let grade_project_id: string;1910let student_path: string;1911let stop_student_project = false;1912if (nbgrader_grade_project) {1913grade_project_id = nbgrader_grade_project;19141915// grade in the path where we collected their work.1916student_path = join(1917assignment.get("collect_path"),1918student.get("student_id"),1919);19201921this.course_actions.configuration.configure_nbgrader_grade_project(1922grade_project_id,1923);1924} else {1925if (student_project_id == null) {1926// This would happen if maybe instructor deletes student project at1927// the exact wrong time.1928// TODO: just create a new project for them?1929throw Error("student has no project, so can't run nbgrader");1930}1931grade_project_id = student_project_id;1932// grade right where student did their work.1933student_path = assignment.get("target_path");1934}19351936const where_grade =1937redux.getStore("projects").get_title(grade_project_id) ?? "a project";19381939const project_name = nbgrader_grade_project1940? `project ${trunc(where_grade, 40)}`1941: `${store.get_student_name(student_id)}'s project`;19421943if (instructor_ipynb_files == null) {1944instructor_ipynb_files =1945await this.nbgrader_instructor_ipynb_files(assignment_id);1946if (this.course_actions.is_closed()) return;1947}1948if (len(instructor_ipynb_files) == 0) {1949/* console.log(1950"run_nbgrader_for_one_student",1951assignment_id,1952student_id,1953"done -- no ipynb files"1954); */1955return; // nothing to do1956}19571958const result: { [path: string]: any } = {};1959const scores: { [filename: string]: NotebookScores | string } = {};19601961const one_file: (file: string) => Promise<void> = async (file) => {1962const activity_id = this.course_actions.set_activity({1963desc: `Running nbgrader on ${store.get_student_name(1964student_id,1965)}'s "${file}" in '${trunc(where_grade, 40)}'`,1966});1967if (assignment == null || student == null) {1968// This won't happen, but it makes Typescript happy.1969return;1970}1971try {1972// fullpath = where their collected work is.1973const fullpath = join(1974assignment.get("collect_path"),1975student.get("student_id"),1976file,1977);1978const student_ipynb: string = await jupyter_strip_notebook(1979course_project_id,1980fullpath,1981);1982if (instructor_ipynb_files == null) throw Error("BUG");1983const instructor_ipynb: string = instructor_ipynb_files[file];1984if (this.course_actions.is_closed()) return;19851986const id = this.course_actions.set_activity({1987desc: `Ensuring ${project_name} is running`,1988});19891990try {1991const did_start = await start_project(grade_project_id, 60);1992// if *we* started the student project, we'll also stop it afterwards1993if (!nbgrader_grade_project) {1994stop_student_project = did_start;1995}1996} finally {1997this.course_actions.clear_activity(id);1998}19992000let ephemeralGradePath;2001try {2002if (2003grade_project_id != course_project_id &&2004grade_project_id != student_project_id2005) {2006ephemeralGradePath = true;2007// Make a fresh copy of the assignment files to the grade project.2008// This is necessary because grading the assignment may depend on2009// data files that are sent as part of the assignment. Also,2010// student's might have some code in text files next to the ipynb.2011await webapp_client.project_client.copy_path_between_projects({2012src_project_id: course_project_id,2013src_path: student_path,2014target_project_id: grade_project_id,2015target_path: student_path,2016overwrite_newer: true,2017delete_missing: true,2018backup: false,2019timeout: COPY_TIMEOUT_MS,2020});2021} else {2022ephemeralGradePath = false;2023}20242025const opts = {2026timeout_ms: store.getIn(2027["settings", "nbgrader_timeout_ms"],2028NBGRADER_TIMEOUT_MS,2029),2030cell_timeout_ms: store.getIn(2031["settings", "nbgrader_cell_timeout_ms"],2032NBGRADER_CELL_TIMEOUT_MS,2033),2034max_output: store.getIn(2035["settings", "nbgrader_max_output"],2036NBGRADER_MAX_OUTPUT,2037),2038max_output_per_cell: store.getIn(2039["settings", "nbgrader_max_output_per_cell"],2040NBGRADER_MAX_OUTPUT_PER_CELL,2041),2042student_ipynb,2043instructor_ipynb,2044path: student_path,2045project_id: grade_project_id,2046};2047/*console.log(2048student_id,2049file,2050"about to launch autograding with input ",2051opts2052);*/2053const r = await nbgrader(opts);2054/* console.log(student_id, "autograding finished successfully", {2055file,2056r,2057});*/2058result[file] = r;2059} finally {2060if (ephemeralGradePath) {2061await webapp_client.project_client.exec({2062project_id: grade_project_id,2063command: "rm",2064args: ["-rf", student_path],2065});2066}2067}20682069if (!nbgrader_grade_project && stop_student_project) {2070const idstop = this.course_actions.set_activity({2071desc: `Stopping project ${project_name} after grading.`,2072});2073try {2074await stop_project(grade_project_id, 60);2075} finally {2076this.course_actions.clear_activity(idstop);2077}2078}2079} catch (err) {2080// console.log("nbgrader failed", { student_id, file, err });2081scores[file] = `${err}`;2082} finally {2083this.course_actions.clear_activity(activity_id);2084}2085};20862087// NOTE: we *could* run multiple files in parallel, but that causes2088// trouble for very little benefit. It's better to run across all students in parallel,2089// and the trouble is just that running lots of code in the same project can confuse2090// the backend api and use extra memory (which is unfair to students being graded, e.g.,2091// if their project has 1GB of RAM and we run 3 notebooks at once, they get "gypped").2092try {2093this.nbgrader_set_is_running(assignment_id, student_id);20942095for (const file in instructor_ipynb_files) {2096await one_file(file);2097}2098} finally {2099this.nbgrader_set_is_done(assignment_id, student_id);2100}2101/* console.log("ran nbgrader for all files for a student", {2102student_id,2103result2104}); */2105// Save any previous nbgrader scores for this student, so we can2106// preserve any manually entered scores, rather than overwrite them.2107const prev_scores = store.get_nbgrader_scores(assignment_id, student_id);21082109const nbgrader_score_ids: { [filename: string]: string[] } = {};21102111for (const filename in result) {2112const r = result[filename];2113if (r == null) continue;2114if (r.output == null) continue;2115if (r.ids != null) {2116nbgrader_score_ids[filename] = r.ids;2117}21182119// Depending on instructor options, write the graded version of2120// the notebook to disk, so the student can see why their grade2121// is what it is:2122const notebook = JSON.parse(r.output);2123scores[filename] = extract_auto_scores(notebook);2124if (2125prev_scores != null &&2126prev_scores[filename] != null &&2127typeof prev_scores[filename] != "string"2128) {2129// preserve any manual scores. cast since for some reason the typeof above isn't enough.2130for (const id in prev_scores[filename] as object) {2131const x = prev_scores[filename][id];2132if (x.manual && x.score && scores[filename][id] != null) {2133scores[filename][id].score = x.score;2134}2135}2136}21372138if (!nbgrader_include_hidden_tests) {2139// IMPORTANT: this *must* happen after extracting scores above!2140// Otherwise students get perfect grades.2141ipynb_clear_hidden_tests(notebook);2142}21432144await this.write_autograded_notebook(2145assignment,2146student_id,2147filename,2148JSON.stringify(notebook, undefined, 2),2149);2150}21512152this.set_nbgrader_scores_for_one_student(2153assignment_id,2154student_id,2155scores,2156nbgrader_score_ids,2157commit,2158);2159};21602161autograded_path = (2162assignment: AssignmentRecord,2163student_id: string,2164filename: string,2165): string => {2166return autograded_filename(2167join(assignment.get("collect_path"), student_id, filename),2168);2169};21702171private write_autograded_notebook = async (2172assignment: AssignmentRecord,2173student_id: string,2174filename: string,2175content: string,2176): Promise<void> => {2177const path = this.autograded_path(assignment, student_id, filename);2178await this.write_text_file_to_course_project({ path, content });2179};21802181open_file_in_collected_assignment = async (2182assignment_id: string,2183student_id: string,2184file: string,2185): Promise<void> => {2186const { assignment, student, store } = this.course_actions.resolve({2187assignment_id,2188student_id,2189});2190if (assignment == null || student == null) {2191throw Error("no such student or assignment");2192}2193const course_project_id = store.get("course_project_id");2194const fullpath = join(2195assignment.get("collect_path"),2196student.get("student_id"),2197file,2198);21992200await redux2201.getProjectActions(course_project_id)2202.open_file({ path: fullpath, foreground: true });2203};22042205private nbgrader_set_is_running = (2206assignment_id: string,2207student_id?: string,2208): void => {2209const store = this.get_store();2210let nbgrader_run_info: NBgraderRunInfo = store.get(2211"nbgrader_run_info",2212Map(),2213);2214const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;2215nbgrader_run_info = nbgrader_run_info.set(key, webapp_client.server_time());2216this.course_actions.setState({ nbgrader_run_info });2217};22182219private nbgrader_set_is_done = (2220assignment_id: string,2221student_id?: string,2222): void => {2223const store = this.get_store();2224let nbgrader_run_info: NBgraderRunInfo = store.get(2225"nbgrader_run_info",2226Map<string, number>(),2227);2228const key = student_id ? `${assignment_id}-${student_id}` : assignment_id;2229nbgrader_run_info = nbgrader_run_info.delete(key);2230this.course_actions.setState({ nbgrader_run_info });2231};22322233export_file_use_times = async (2234assignment_id: string,2235json_filename: string,2236): Promise<void> => {2237// Get the path of the assignment2238const { assignment, store } = this.course_actions.resolve({2239assignment_id,2240});2241if (assignment == null) {2242throw Error("no such assignment");2243}2244const src_path = this.assignment_src_path(assignment);2245const target_path = assignment.get("path");2246await export_student_file_use_times(2247store.get("course_project_id"),2248src_path,2249target_path,2250store.get("students"),2251json_filename,2252store.get_student_name.bind(store),2253);2254};22552256export_collected = async (assignment_id: string): Promise<void> => {2257const set_activity = this.course_actions.set_activity.bind(2258this.course_actions,2259);2260const id = set_activity({2261desc: "Exporting collected files...",2262});2263try {2264const { assignment, store } = this.course_actions.resolve({2265assignment_id,2266});2267if (assignment == null) return;2268const students = store.get("students");2269const src_path = this.assignment_src_path(assignment);2270const collect_path = assignment.get("collect_path");2271const i = store.get("course_filename").lastIndexOf(".");2272const base_export_path =2273store.get("course_filename").slice(0, i) + "-export";2274const export_path = join(base_export_path, src_path);22752276const student_name = function (student_id: string): string {2277const v = split(store.get_student_name(student_id));2278return v.join("_");2279};22802281const activity = function (s: string): void {2282set_activity({2283id,2284desc: "Exporting collected files... " + s,2285});2286};22872288const project_id = store.get("course_project_id");22892290await export_assignment(2291project_id,2292collect_path,2293export_path,2294students,2295student_name,2296activity,2297);22982299redux.getProjectActions(project_id).open_directory(base_export_path);2300} catch (err) {2301this.course_actions.set_error(2302`Error exporting collected student files -- ${err}`,2303);2304} finally {2305set_activity({ id });2306}2307};2308}230923102311