UNPKG

grading

Version:

Grading of student submissions, in particular programming tests.

341 lines (300 loc) 13.6 kB
import { OptionValues } from 'commander'; import { copyFileSync } from 'fs'; import * as fs from 'fs/promises'; import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import { loadTableWithEncoding } from '../csv'; import { createDir, fileExists, folderExists, readDir } from '../fsUtil'; import { GradingSchema, readGradingSchema } from '../grade/gradingschema'; import { Table } from '../table'; import { error, generateFileName, log, verb, verbosity, warn } from './cliUtil'; import { writeSettings } from './settings'; import { MANUAL_CONCLUSION_SCHEMA_URL, MANUAL_CORRECTIONS_SCHEMA_URL } from '../constants'; /** * Location of default files in this package. */ const DEFAULT_FILES = "defaultFiles"; export async function cmdInit(options: OptionValues) { verbosity(options); try { const schemaFile = options.gradingSchemaFile; let gradingSchema: GradingSchema | undefined = undefined; if (await fileExists(schemaFile)) { gradingSchema = await readGradingSchema(schemaFile); } if (options.generateCorrectionsFile) { const latestResultFile = await findLatestResultFile(options.resultFile, { examName: gradingSchema?.exam } ); let results: Table | undefined = undefined; if (latestResultFile) { results = await loadTableWithEncoding(latestResultFile, options.encoding, options.resultCSVDelimiter); } else { log(`No result file found for exam ${gradingSchema?.exam}, cannot prefill users in corrections file. Tip: generate a result file first (by running the grade command)!`) } gradingSchema = await readGradingSchema(schemaFile); await writeCorrectionsFile(options.manualCorrectionsFile, options, gradingSchema, results); if (gradingSchema) { verb("Corrections file created, skip other steps since grading schema is already available."); return; } } if (options.generateManualConclusionFile) { const latestResultFile = await findLatestResultFile(options.resultFile, { examName: gradingSchema?.exam } ); let results: Table | undefined = undefined; if (latestResultFile) { results = await loadTableWithEncoding(latestResultFile, options.encoding, options.resultCSVDelimiter); } await writeConclusionFile(options.manualConclusionFile, options, gradingSchema, results); if (gradingSchema) { verb("Conclusion file created, skip other steps."); return; } } const checkDir = options.checkDir; if (! await folderExists(checkDir, 'checkDir')) { await createDir(checkDir); } else { log(`Folder ${checkDir} already exists.`) } const packageName = "grading" const baseStart = __dirname.lastIndexOf(packageName); if (baseStart < 0) { program.error("Unable to find grading package folder."); } const packageFile = __dirname.substring(0, baseStart + packageName.length); const defaultFiles = await readDir(path.join(packageFile, DEFAULT_FILES), 'file', true); for (const defaultFile of defaultFiles) { const srcFile = path.join(packageFile, DEFAULT_FILES, defaultFile); const targetFile = path.join(checkDir, defaultFile); const targetDir = path.dirname(targetFile); if (targetDir != '.') { await createDir(targetDir); } if (await fileExists(targetFile)) { log(`File ${targetFile} already exists, skipped.`); } else { verb(`Copy ${defaultFile}`) copyFileSync(srcFile, targetFile); } } log(`Default files created in ${checkDir}`) // .option('--stdInFolder', 'Name of folder to be created for input files (and added to .gitignore).', 'gradingIn') // .option('--stdOutFolder', 'Name of folder to be created for output files (and added to .gitignore).', 'gradingOut') if (!options.noFolders) { if (await folderExists(options.stdInFolder, 'stdInFolder')) { log(`${options.stdInFolder} already exists.`); } else { await createDir(options.stdInFolder); log(`Created ${options.stdInFolder} where you should place files downloaded from Moodle.`); } } if (!options.noGitignore) { if (!await fileExists('.gitignore')) { log(`.gitignore does not exists, please ensure to add ${options.stdInFolder} and ${options.stdOutFolder} manually.`) } else { let gitignore = (await readFile('.gitignore')).toString(); const addIn = !new RegExp(`^${options.stdInFolder}$`, 'm').test(gitignore); const addOut = !new RegExp(`^${options.stdOutFolder}$`, 'm').test(gitignore); if (addIn || addOut) { if (gitignore.charAt(gitignore.length - 1) != '\n') { gitignore += "\n"; } gitignore += "# Grading input and output folders\n" if (addIn) gitignore += `${options.stdInFolder}\n` if (addOut) gitignore += `${options.stdOutFolder}\n` await writeFile('.gitignore', gitignore) if (addIn && addOut) { log(`Added ${options.stdInFolder} and${options.stdOutFolder} to .gitignore`); } else if (addIn) { log(`Added ${options.stdInFolder} to .gitignore, ${options.stdOutFolder} already present`); } else if (addOut) { log(`Added ${options.stdOutFolder} to .gitignore, ${options.stdInFolder} already present`); } } else { log(`${options.stdInFolder} and ${options.stdOutFolder} already found in .gitignore`); } } } if (options.generateSettings) { writeSettings(); } log(` In order to not run into problems in your (solution) project, add the following entries to your configuration settings (besides .gitignore): - tsconfig.ts: add "include": ["src/**/*"] at the end if not already present - jest.config.js: add gradingOut to ignored folders in order to prevent testing of submissions in your daily work, i.e. add the following lines: testPathIgnorePatterns: ["<rootDir>/dist/", "<rootDir>/node_modules/", "<rootDir>/gradingOut/"], modulePathIgnorePatterns: ["<rootDir>/gradingOut/"], coveragePathIgnorePatterns: ["<rootDir>/gradingOut/"] `) } catch (err) { error(`${SEP}`) // error(`${SEP}\nError: ${err}`); program.error(String(err)); } } async function writeCorrectionsFile(manualCorrectionsFile: string, options: OptionValues, gradingSchema?: GradingSchema, results?: Table) { if (!manualCorrectionsFile) { error("No manualCorrectionsFile provided, cannot generate initial correction file"); return; } const correctionFileName = generateFileName(manualCorrectionsFile, options); if (await fileExists(correctionFileName)) { log(`Corrections file '${correctionFileName}' already exists, do not overwrite.`) return; } const folder = path.dirname(correctionFileName); if (!await folderExists(folder)) { await createDir(folder, true); } const taskInGeneratedCorrectionsFile = Boolean(options.taskInGeneratedCorrectionsFile); let sampleTasks: string; if (gradingSchema) { sampleTasks = gradingSchema.tasks.map(task => `{ "name": "${task.name}", "points": 0, "reason": "" }`).join(","); } else { sampleTasks = ` { "name": "Task name", "points": 0, "reason": "" }`; } let userList = ""; if (results) { userList = results.rawArray.map(row => "\n" + ` // { "submissionID": "${row[3]}", "userName": "${row[4]}", /* ${row[7]} */ "general": [{"points": 0, "reason": "" }]` + (taskInGeneratedCorrectionsFile ? `, "tasks": [${sampleTasks}]` : "") + "}") .filter((_, index) => index > 0).join(","); } const content = `{ "$schema": "${MANUAL_CORRECTIONS_SCHEMA_URL}", "exam": "${gradingSchema?.exam || "Blatt x"}", "course": "${gradingSchema?.course || "Course"}", "term": "${gradingSchema?.term || computeTerm()}", "corrections": [${userList} /* { "submissionID": "xxx", "userName": "first last", "general": [ { "points": 0, "reason": "General remarks or points." } ], "tasks": [${sampleTasks} ] }, */ ] }`; log(`Write manual correction file ${correctionFileName}`); await fs.writeFile(correctionFileName, content, 'utf8'); } function findEndOfBlock(s: string, offset: number) { let counter = 0; for (let i = offset + 1; i < s.length; i++) { if (s[i] == '}') { counter--; if (counter < 0) { return i; } } else if (s[i] == '{') { counter++; } } return -1; } async function findLatestResultFile(resultFileTemplate: string, templateVars: { examName?: string }) { const resultFiles = await findResultFiles(resultFileTemplate, templateVars); if (resultFiles.length == 0) { return undefined; } return resultFiles[resultFiles.length - 1]; } async function findResultFiles(resultFileTemplate: string, templateVars: { examName?: string }) { const resultFileName = generateFileName(resultFileTemplate, templateVars, false); verb(`Search for latest result file ${resultFileName}`); const ext = path.extname(resultFileName); if (!ext || ext.includes('${')) { log(`Cannot find latest result file, extension not found or it contains unresolved template variables: ${ext}`); return []; } const resultsDir = path.dirname(resultFileName); const base = path.basename(resultFileName, ext); if (resultsDir.includes('${')) { log(`Cannot find latest result file, directory name contains unresolved template variables: ${resultsDir}`); return []; } const prefixIndex = base.indexOf('${'); const namePrefix = (prefixIndex >= 0) ? base.substring(0, prefixIndex) : base; const resultFiles = (await readDir(resultsDir, 'file', false, 'All results folder.')) .filter(entry => { return (path.basename(entry).startsWith(namePrefix) && path.extname(entry) === ext) }) .sort((a, b) => a.localeCompare(b)) .map(entry => path.join(resultsDir, entry)); if (resultFiles.length == 0) { verb(`No result files found matching ${resultsDir}/${namePrefix}.${ext}`); } return resultFiles; } function computeTerm() { const now = new Date(); const year = now.getFullYear() - 2000; const month = now.getMonth(); if (month >= 4 && month <= 9) { // Summer term return `SS 20${year}`; } else { // Winter term if (month < 4) { // Winter term of previous year return `WS 20${year - 1}/${year}`; } else { // Winter term of current year return `WS 20${year}/${year + 1}`; } } } async function writeConclusionFile(manualConclusionFile: string, options: OptionValues, gradingSchema?: GradingSchema, results?: Table) { if (!manualConclusionFile) { error("No manualConclusionFile provided, cannot generate initial conclusion file"); return; } const conclusionFileName = generateFileName(manualConclusionFile, { resultsDir: options.resultsDir, stdInFolder: options.stdInFolder }); if (await fileExists(conclusionFileName)) { warn(`Conclusion file '${conclusionFileName}' already exists, do not overwrite.`) return; } const folder = path.dirname(conclusionFileName); if (!await folderExists(folder)) { error(`Conclusion file path '${folder}' does not exists (and it is not automatically created).`) return; } let userList = ""; if (results) { userList = results.rawArray.map(row => ` // {"userName": "${row[4]}", "totalGrading": 0.0, "gradingReason": "", "generalRemark": "" }`).filter((_, index) => index > 0).join(","); } const content = `{ "$schema": "${MANUAL_CONCLUSION_SCHEMA_URL}", "course": "${gradingSchema?.course || "Course"}", "term": "${gradingSchema?.term || computeTerm()}", "conclusions": [${userList} /* { "userName": "first last", "totalGrading": 0.0, "gradingReason": "Reason for total grading.", "generalRemark": "General remark." }, */ ], "aliases": [ // for renamed students (or names changed otherwise, e.g., typos) // ["currentFirst currentLast", "oldFirst oldLast"],]] ] }`; log(`Write manual conclusion file ${conclusionFileName}`); await fs.writeFile(conclusionFileName, content, 'utf8'); }