UNPKG

grading

Version:

Grading of student submissions, in particular programming tests.

799 lines (705 loc) 33.8 kB
import { OptionValues } from "commander"; import { parse } from "comment-json"; import { CellFormulaValue, CellIsRuleType, ExpressionRuleType } from "exceljs"; import fs from "fs"; import path from "path"; import { loadTableWithEncoding, parseGrading, writeTableWithEncoding } from "../csv"; import { asNumber, bgLightGray, bgLightGreen, bgLightRed, bgLightYellow, bgVeryLightGray, forEachCellInRow, headerRow, toCol, writeExcel } from "../excel"; import { EXT_EXCEL, createDir, fileExists, folderExists, readDir } from "../fsUtil"; import { ManualConclusion } from "../grade/manualconclusion"; import { Table } from "../table"; import { error, generateFileName, log, verb, verbosity, warn } from "./cliUtil"; import { compareNumberAware } from "./compareNumberAware"; import { copyFile, rename, rm } from "fs/promises"; type Fields<T> = { [Property in keyof T as T[Property] extends Function ? never : Property]: T[Property] }; export class ExamResult { submissionID: string; name: string; exam: string; submitted: boolean; grade: number; points: number; constructor(submissionID: string, name: string, exam: string | undefined, submitted: boolean, grade: number, points: number) { if (!submissionID || !name || !exam || grade < 0 || points < 0) { throw new Error(`Invalid exam result: SubmissionID ${submissionID}, name ${name}, exam ${exam}, submitted ${submitted}, grade ${grade}, points ${points}`); } this.submissionID = submissionID; this.name = name; this.exam = exam; this.submitted = submitted; this.grade = grade; this.points = points; } } export class AllExamResults { examsByStudent: Map<string, ExamResult[]> = new Map(); addExamResult(examResult: ExamResult) { let examResults = this.examsByStudent.get(examResult.name); if (!examResults) { examResults = []; this.examsByStudent.set(examResult.name, examResults); } if (examResults.some(er => er.exam === examResult.exam)) { throw new Error(`Duplicate exam result found for ${examResult.name} in ${examResult.exam} -- does the folder contain multiple results for same exam?`); } examResults.push(examResult); } } // export for tests export interface ExamGrade { exam: string; grade: number; selected: boolean; } // export for tests export class Conclusion { name: string; submissions: number; /** * Number of submissions with grade >= 4 */ successful: number; /** * Number of submissions failed (grade > 4) in * range [0..totalExams] */ failed: number; /** * Number of submissions missing to compute the final grade * in range [0..selectBest] * There may be more submissions missing, but they are not counted. */ missing: number; /** * Actually submitted exams with their grade. */ examGrades: ExamGrade[]; /** * Average grade of all submitted exams as string, * empty string if no exams submitted. */ averageGrade: string; maxGrade: string; finalGrade: string; minGrade: string; remark?: string; constructor(src: Fields<Conclusion>) { this.name = src.name; this.submissions = src.submissions; this.successful = src.successful; this.failed = src.failed; this.missing = src.missing; this.examGrades = src.examGrades; this.averageGrade = src.averageGrade; this.finalGrade = src.finalGrade; this.minGrade = src.minGrade; this.maxGrade = src.maxGrade; this.remark = src.remark; } getFinalGradeAsNumber(): number { return parseGrading(this.finalGrade); } getGradeOrNA() { } } export async function cmdConclude(options: OptionValues, runInternally = false) { verbosity(options); const resultsDir: string = options.resultsDir; const resultFile: string = options.resultFile; const encoding: string = options.encoding; const resultCSVDelimiter: string = options.resultCSVDelimiter; const conclusionFile: string = options.conclusionFile; const selectBest: number = options.selectBest; const totalExams: number = options.totalExams; const maxFailed: number = options.maxFailed; const deprecatedResultsDir: string = options.deprecatedResultsDir; const autoCopyResult: boolean = options.autoCopyResult; const autoCopyConclusion: boolean = options.autoCopyConclusion; const keepOldResult: boolean = options.keepOldResult; const noFinalGradeBefore: string = options.noFinalGradeBefore; try { const allResultCSVs = await retrieveResultCSVFilenames(resultsDir); if (autoCopyResult) { await doAutoCopyResult(resultFile, resultsDir, allResultCSVs, keepOldResult, deprecatedResultsDir); } else { verb(`Auto copy results disabled.`); } if (allResultCSVs.length == 0) { error(`No result CSV files found in ${resultsDir}`); return; } const manualConclusion = await readManualConclusion(options); checkManualConclusion(manualConclusion); const allExamResults = new AllExamResults(); verb('Reading result CSVs...') await readAllResults(allResultCSVs, resultsDir, encoding, resultCSVDelimiter, allExamResults); normalizeAliases(manualConclusion, allExamResults); verb('Calculating conclusions...'); // Collect all submissions const { conclusions, examTitles, examTitlesWithUsage } = calculateConclusions(allExamResults, totalExams, selectBest, maxFailed, noFinalGradeBefore, manualConclusion); // Create table const conclusionFileName = await createAndWriteConclusionTable(examTitlesWithUsage, conclusions, examTitles, conclusionFile, selectBest, maxFailed, totalExams, encoding, resultCSVDelimiter); if (autoCopyConclusion) { const resultWithConclusion = path.join(resultsDir, path.basename(conclusionFileName)); log(`Copy ${conclusionFileName} to ${resultWithConclusion}`); await copyFile(conclusionFileName, resultWithConclusion); } else { verb(`Auto copy conclusion disabled.`); } } catch (err) { error(err); program.error(String(err)); } if (!runInternally) { log(`${SEP}\nDone`); } } async function createAndWriteConclusionTable(examTitlesWithUsage: string[], conclusions: Conclusion[], examTitles: string[], conclusionFile: string, selectBest: number, maxFailed: number, totalExams: number, encoding: string, resultCSVDelimiter: string) { const conclusionTable = new Table(); conclusionTable.addRow('Name', 'Final Grade', 'Submissions', 'Success', 'Missing', 'Failed', 'Avg', 'Min', 'Max', ...examTitlesWithUsage, "Calc", "Kommentar"); for (const conclusion of conclusions) { conclusionTable.addRow( conclusion.name, conclusion.finalGrade, conclusion.submissions, conclusion.successful, conclusion.missing, conclusion.failed, conclusion.averageGrade, conclusion.minGrade, conclusion.maxGrade, ...usedExams(examTitles, conclusion), '', conclusion.remark || '' ); } const firstExam = examTitles[0]; const lastExam = examTitles.length > 0 ? examTitles[examTitles.length - 1] : undefined; const conclusionFileName = generateFileName(conclusionFile, { dateTime: new Date().getTime(), firstExam, lastExam }); if (conclusionFileName.endsWith(EXT_EXCEL)) { await writeTableAsExcel(conclusionFileName, conclusionTable, selectBest, maxFailed, totalExams); } else { await writeTableWithEncoding(conclusionFileName, conclusionTable, encoding, resultCSVDelimiter); } log(`Wrote conclusion to ${conclusionFileName}`); return conclusionFileName; } export function calculateConclusions(allExamResults: AllExamResults, totalExams: number, selectBest: number, maxFailed: number, noFinalGradeBefore: string, manualConclusion: ManualConclusion | undefined): { examTitlesWithUsage: string[]; conclusions: Conclusion[]; examTitles: string[]; } { const examTitlesSet = new Set<string>(); const conclusions: Conclusion[] = []; let maxSubmissions = 0; // 1. Creates a conclusion with exam grades for each student. Only submitted exames are added here and no grade is selected yet. for (const examResults of allExamResults.examsByStudent.values()) { const conclusion = new Conclusion({ name: examResults[0].name, submissions: 0, successful: 0, missing: 0, failed: 0, examGrades: [], averageGrade: "", finalGrade: "", minGrade: "", maxGrade: "", }); for (const examResult of examResults) { if (examResult.submitted) { examTitlesSet.add(examResult.exam); conclusion.submissions++; maxSubmissions = Math.max(maxSubmissions, conclusion.submissions); conclusion.examGrades.push({ exam: examResult.exam, grade: examResult.grade, selected: false }); } } conclusions.push(conclusion); } // Actually compute the conclusion values: for (const conclusion of conclusions) { computeConclusionValues(conclusion, maxSubmissions, totalExams, selectBest, maxFailed, noFinalGradeBefore); } const examTitles = Array.from(examTitlesSet).sort(compareNumberAware); const examTitlesWithUsage = examTitles.map(title => [title, "x"]).flat(); conclusions.sort((a, b) => a.name.localeCompare(b.name)); const appliedManualConclusions: string[] = []; if (manualConclusion) { manualConclusion.conclusions.forEach(manualConclusion => { let conclusion = conclusions.find(conclusion => conclusion.name === manualConclusion.userName); if (!conclusion) { verb(`Manual conclusion for ${manualConclusion.userName} not found in computed results, create new one (probably grading from previous term).`); conclusion = new Conclusion({ name: manualConclusion.userName, submissions: 0, successful: 0, missing: 0, failed: 0, examGrades: [], averageGrade: "", finalGrade: "", minGrade: "", maxGrade: "", }); conclusions.push(conclusion); } if (manualConclusion.totalGrading) { conclusion.finalGrade = toNumWithComma(manualConclusion.totalGrading); conclusion.remark = manualConclusion.gradingReason; appliedManualConclusions.push(`${manualConclusion.userName} (${conclusion.finalGrade})`); } if (manualConclusion.generalRemark) { if (conclusion.remark) { conclusion.remark += ". " + manualConclusion.generalRemark; } else { conclusion.remark = manualConclusion.generalRemark; } } }); } verb(`Found ${conclusions.length} students.`); if (appliedManualConclusions.length > 0) { log('Applied manual grading for: ' + appliedManualConclusions.join(', ')); } verb("Max. number of submissions per student: " + maxSubmissions); verb("Number of different exams found: " + examTitles.length); verb("Number of total exams: " + totalExams); if (maxSubmissions !== examTitles.length) { warn(`Number of different exams (${examTitles.length}) differs from max. number of submissions (${maxSubmissions}).`); } return { examTitlesWithUsage, conclusions, examTitles }; } export function normalizeAliases(manualConclusion: ManualConclusion | undefined, allExamResults: AllExamResults) { if (manualConclusion?.aliases) { for (const alias of manualConclusion.aliases) { const currentName = alias[0]; const currentResults = allExamResults.examsByStudent.get(currentName); for (let i = 1; i < alias.length; i++) { const aliasName = alias[i]; const aliasResults = allExamResults.examsByStudent.get(aliasName); if (aliasResults) { for (const examResult of aliasResults) { examResult.name = currentName; } allExamResults.examsByStudent.delete(aliasName); if (!currentResults) { allExamResults.examsByStudent.set(currentName, aliasResults); } else { for (const aliasResult of aliasResults) { const currentResult = currentResults.find(cr => cr.exam === aliasResult.exam); if (currentResult) { if (currentResult.grade === aliasResult.grade) { warn(`Alias ${aliasName} has same grade for ${aliasResult.exam} as ${currentName}, skipping.`); } else { throw new Error(`Alias ${aliasName} has different grade for ${aliasResult.exam} as ${currentName}.`); } } else { currentResults.push(aliasResult); log(`${aliasResult.exam} submitted as ${aliasName}, merged to ${currentName}.`); } } } } } } } } async function doAutoCopyResult(resultFile: string, resultsDir: string, allResultCSVs: string[], keepOldResult: boolean, deprecatedResultsDir: string) { const currentResultDir = path.dirname(generateFileName(resultFile, {})); // may not be complete, we only need parent if (resultsDir === currentResultDir) { verb(`Auto copy results disabled, resultsDir and currentResultDir are the same.`); } else { const currentResultCSVs = (await readDir(currentResultDir, 'file', false, 'Current results folder.')) .filter(entry => { const lower = entry.toLowerCase(); return lower.startsWith('results') && lower.endsWith('csv'); }) .sort((a, b) => a.localeCompare(b)); const latestResultCSVs = currentResultCSVs[currentResultCSVs.length - 1]; if (latestResultCSVs) { const latestResultCSVBase = path.basename(latestResultCSVs); if (allResultCSVs.some(resultFile => resultFile === latestResultCSVBase)) { log(`Latest result CSV ${latestResultCSVBase} already in ${resultsDir}, skipping auto copy.`); } else { if (keepOldResult) { const latestExamFromFilename = parseExamFromFilename(latestResultCSVBase); const similarExamResultFile = allResultCSVs.find(resultFile => { const examFromFilename = parseExamFromFilename(resultFile); return examFromFilename === latestExamFromFilename; }); if (similarExamResultFile) { const similarExamResultCSV = path.join(resultsDir, similarExamResultFile); const deprecatedResultsFolder = generateFileName(deprecatedResultsDir, { resultsDir }); const target = path.join(deprecatedResultsFolder, similarExamResultFile); log(`Move old result file for exam ${latestExamFromFilename} from '${similarExamResultCSV}' to '${target}'`); if (!await folderExists(deprecatedResultsFolder, "Deprecated results folder.")) { await createDir(deprecatedResultsFolder); } await copyFile(similarExamResultCSV, target); await rm(similarExamResultCSV); const index = allResultCSVs.findIndex(resultFile => resultFile === similarExamResultFile); allResultCSVs.splice(index, 1); } else { verb(`No other exam file for ${latestExamFromFilename} found in ${resultsDir}`); } } const absCurrentResult = path.join(currentResultDir, latestResultCSVs); const absAllResultCurrent = path.join(resultsDir, latestResultCSVs); log(`Copy ${absCurrentResult} to ${absAllResultCurrent}`); await copyFile(absCurrentResult, absAllResultCurrent); allResultCSVs.push(latestResultCSVs); allResultCSVs.sort((a, b) => a.localeCompare(b)); } } else { verb(`No latest result CSV found in ${currentResultDir}`); } } } export async function readAllResults(allResultCSVs: string[], resultsDir: string, encoding: string, resultCSVDelimiter: string, allExamResults: AllExamResults) { const resultsForExam: Map<string, string> = new Map(); for (const resultCSV of allResultCSVs) { try { const fullName = path.join(resultsDir, resultCSV); verb(` Reading result CSV '${fullName}'...`); const results = await loadTableWithEncoding(fullName, encoding, resultCSVDelimiter); const colSubmissionID = results.getColForTitles('submissionID', 'userID'); const colName = results.getColForTitles('name'); const colSubmissionFlag = results.getColForTitles('abgabe', "submitted"); const colGrade = results.getColForTitles('note', "grade"); const colPoints = results.getColForTitles('punkte', "points"); let colExam = -1; const examFromFilename = parseExamFromFilename(resultCSV); try { colExam = results.getColForTitles('exam'); } catch (err) { if (!examFromFilename) { throw Error(`No exam column found in ${resultCSV} and no exam could be parsed from filename.`); } } if (examFromFilename !== undefined && resultsForExam.has(examFromFilename)) { throw new Error(`Duplicate result file for exam '${examFromFilename}': ${resultCSV} and ${resultsForExam.get(examFromFilename)}.`); } if (colSubmissionID < 0) throw new Error(`No column 'submissionID' found in ${resultCSV}`); if (colName < 0) throw new Error(`No column 'name' found in ${resultCSV}`); if (colSubmissionFlag < 0) throw new Error(`No column 'abgabe' found in ${resultCSV}`); if (colGrade < 0) throw new Error(`No column 'note' found in ${resultCSV}`); if (colPoints < 0) throw new Error(`No column 'punkte' found in ${resultCSV}`); for (let row = 2; row <= results.rowsCount; row++) { const submissionID = results.getText(colSubmissionID, row); const name = results.getText(colName, row); const submitted = results.getText(colSubmissionFlag, row) == '1'; const grade = parseGrading(results.getText(colGrade, row)); const points = parseGrading(results.getText(colPoints, row)); const exam = colExam > 0 ? results.getText(colExam, row) : examFromFilename; const examResult = new ExamResult(submissionID, name, exam, submitted, grade, points); allExamResults.addExamResult(examResult); } } catch (err) { error(`Error reading result CSV '${resultCSV}'`); error(err); } }; } export async function retrieveResultCSVFilenames(resultsDir: string): Promise<string[]> { return (await readDir(resultsDir, 'file', false, 'All results folder.')) .filter(entry => { const lower = entry.toLowerCase(); return lower.startsWith('results') && lower.endsWith('csv'); }) .sort((a, b) => a.localeCompare(b)); } /** * Computes conclusion values for a student. * * @param conclusion The current conclusion to fill * @param maxSubmissions the max submissions for any student, i.e. the currently processed exams * @param totalExames the number of exams in total * @param selectBest number of submissions to select for final grade * @param maxFailed number of submissions failed (or not submitted) before to set final grade to 5,0 */ export function computeConclusionValues(conclusion: Conclusion, maxSubmissions: number, totalExames: number, selectBest: number, maxFailed: number, noFinalGradeBefore: string) { // similar for all students, computed here for better readability (instead of passing args) const openSubmissions = totalExames - maxSubmissions; const surlyCounted = Math.max(0, selectBest - openSubmissions); // sort ascending conclusion.examGrades.sort((a, b) => a.grade - b.grade); let used = 0; let subSum = 0; let minSum = 0; let doFinalGrade = false; conclusion.examGrades.forEach(grade => { if (grade.exam >= noFinalGradeBefore) { doFinalGrade = true; } if (used < selectBest) { // we still need grades grade.selected = true; subSum += grade.grade; used++; if (used <= surlyCounted) { minSum += grade.grade; } }; if (grade.grade > 4) { conclusion.failed++; } else { conclusion.successful++; } }); const submitted = conclusion.examGrades.length; conclusion.missing = selectBest - used; // missing submissions for this student, this may include past submissions conclusion.failed += maxSubmissions - submitted; // see above: surlyCountedInCourse = selectBest - openSubmissionsInCourse; const averageGrade = used == 0 ? 0 : subSum / used; const maxGrade = (subSum + (5 * conclusion.missing)) / selectBest; const finalGrade = (totalExames <= maxSubmissions) ? maxGrade : 0; const notSubmittedButCounting = Math.max(0, surlyCounted - submitted); const minGrade = ( minSum + (5 * notSubmittedButCounting) // surlyCountedInCourse! + (1 * Math.min(openSubmissions, selectBest - surlyCounted)) ) / selectBest; conclusion.averageGrade = averageGrade > 0 ? roundToGrade(averageGrade) : ""; conclusion.maxGrade = roundToGrade(maxGrade); conclusion.minGrade = roundToGrade(minGrade); if (!doFinalGrade) { conclusion.finalGrade = "-"; } else { conclusion.finalGrade = finalGrade > 0 ? roundToGrade(finalGrade) : ""; if (conclusion.finalGrade && conclusion.minGrade !== conclusion.maxGrade) { warn(`Final grade is set, but min and max grade differ for ${conclusion.name}`); } if (!conclusion.finalGrade && conclusion.minGrade === conclusion.maxGrade) { conclusion.finalGrade = conclusion.minGrade; } if (maxFailed >= 0 && conclusion.failed > maxFailed) { conclusion.finalGrade = "5,0"; }; } } function parseExamFromFilename(filename: string): string | undefined { const match = filename.match(/results_(.+)_.*\.csv/i); if (!match) { return undefined } return match[1]; } /** * RSPO 2016: * Bei der Mittelung von Noten erfolgt nach einer arithmetischen Berechnung eine Rundung, * indem die nächstgelegene Note vergeben wird. Ergibt sich bei der Mittelung ein Zahlenwert, * der genau zwischen zwei Notenstufen liegt, so ist die bessere Note zu vergeben. * */ function roundToGrade(averageGrade: number): string { // export const GRADE_VALUES = [ // // 0 1 2 3 4 5 6 7 8 9 10 // "5,0", "4,0", "3,7", "3,3", "3,0", "2,7", "2,3", "2,0", "1,7", "1,3", "1,0" // // 50% // ] if (averageGrade > (4.3 + 4.0) / 2) return "5,0"; if (averageGrade > (4.0 + 3.7) / 2) return "4,0"; if (averageGrade > (3.7 + 3.3) / 2) return "3,7"; if (averageGrade > (3.3 + 3.0) / 2) return "3,3"; if (averageGrade > (3.0 + 2.7) / 2) return "3,0"; if (averageGrade > (2.7 + 2.3) / 2) return "2,7"; if (averageGrade > (2.3 + 2.0) / 2) return "2,3"; if (averageGrade > (2.0 + 1.7) / 2) return "2,0"; if (averageGrade > (1.7 + 1.3) / 2) return "1,7"; if (averageGrade > (1.3 + 1.0) / 2) return "1,3"; return "1,0"; } function usedExams(examTitles: string[], conclusion: Conclusion): (string | number)[] { let usedExams = 0; let selectedGrades = new Set<ExamGrade>(); const examsWithUsage: (string | number)[] = []; for (const examTitle of examTitles) { const grade = conclusion.examGrades.find(grade => grade.exam === examTitle); if (grade) { examsWithUsage.push(toNumWithComma(grade.grade)); examsWithUsage.push(grade.selected ? 1 : 0); } else { examsWithUsage.push(''); examsWithUsage.push(''); } } return examsWithUsage; } function toNumWithComma(num: number): string { return num.toString().replace('.', ','); } const nameWidth = 29.6; const midWidth = 10; const examGradeWidth = 6.7; const examSelectedWidth = 1.8; function colLtr(col: number): string { const A = 'A'.charCodeAt(0); return String.fromCharCode(A + col - 1); } async function writeTableAsExcel(conclusionFileName: string, conclusionTable: Table, selectBest: number, maxFailed: number, totalExams: number) { const COL_FINAL = 2; // column B const COL_SUB = 3; // column C const COL_SUCCESS = 4; // column D const COL_MISSING = 5; // column E const COL_FAILED = 6; // column F const COL_AVG = 7; // column G const COL_MIN = 8; // column H const COL_MAX = 9; // column I const COL_GRADING = 10; // column J await writeExcel(conclusionTable, conclusionFileName, (ws) => { ws.name = 'Conclusion'; ws.getColumn(1).width = nameWidth; [COL_FINAL, COL_SUB, COL_SUCCESS, COL_MISSING, COL_FAILED].forEach(col => ws.getColumn(col).width = midWidth); forEachCellInRow(ws, COL_FINAL, 2, (cell) => cell.font = { bold: true }); forEachCellInRow(ws, COL_FINAL, 2, (cell) => cell.alignment = { horizontal: 'center' }); [COL_AVG, COL_MIN, COL_MAX].forEach(col => ws.getColumn(col).width = examGradeWidth); [COL_FINAL, COL_AVG, COL_MIN, COL_MAX].forEach(col => forEachCellInRow(ws, col, 2, (cell) => cell.numFmt = '0.0')); [COL_SUB, COL_SUCCESS, COL_MISSING, COL_FAILED].forEach(col => forEachCellInRow(ws, col, 2, (cell) => cell.numFmt = '0')); headerRow(ws, 1); let sqRef: string[] = []; for (let col = COL_GRADING; col < conclusionTable.columnsCount - 2; col += 2) { ws.getColumn(col).width = examGradeWidth; forEachCellInRow(ws, col, 2, (cell) => { cell.numFmt = '0.0'; }); const colStr = toCol(col); sqRef.push(`${colStr}2:${colStr}${conclusionTable.rowsCount}`); ws.getColumn(col + 1).width = examSelectedWidth; forEachCellInRow(ws, col + 1, 2, (cell) => { cell.fill = bgLightGray cell.numFmt = '0'; }); } forEachCellInRow(ws, conclusionTable.columnsCount - 1, 2, (cell) => { const row = cell.row; const sumRef: string[] = []; const countRef: string[] = []; for (let col = COL_GRADING; col < conclusionTable.columnsCount - 2; col += 2) { sumRef.push(`${toCol(col)}${row}*${toCol(col + 1)}${row}`); countRef.push(`${toCol(col + 1)}${row}`); } const sumUsed = sumRef.join('+'); const sumCount = countRef.join("+"); cell.value = { formula: `((${sumUsed})+(${selectBest}-(${sumCount}))*5)/${selectBest}`, result: asNumber(conclusionTable.getText(2, parseInt(row))) } as CellFormulaValue; cell.numFmt = '0.00'; cell.fill = bgLightYellow; }); ws.addConditionalFormatting({ // selected exams ref: sqRef.join(" "), rules: [ { type: 'expression', formulae: [`${colLtr(COL_GRADING + 1)}2`], style: { fill: bgLightGreen }, } as ExpressionRuleType ] }); ws.addConditionalFormatting({ // graded with 5,0 ref: sqRef.join(" "), rules: [ { type: 'cellIs', operator: 'greaterThan', formulae: [4], style: { font: { color: { argb: 'FF9C0000' }, bold: true } }, priority: 2 } as CellIsRuleType ] }); ws.addConditionalFormatting({ // final grade, min, max grade ref: `${colLtr(COL_FINAL)}2:${colLtr(COL_FINAL)}${conclusionTable.rowsCount} ${colLtr(COL_MIN)}2:${colLtr(COL_MIN)}${conclusionTable.rowsCount} ${colLtr(COL_MAX)}2:${colLtr(COL_MAX)}${conclusionTable.rowsCount}`, rules: [ { type: 'cellIs', operator: 'equal', formulae: ['"-"'], style: { fill: bgVeryLightGray }, // priority: 2 } as CellIsRuleType, { type: 'cellIs', operator: 'greaterThan', formulae: [4], style: { fill: bgLightRed }, // priority: 1 } as CellIsRuleType, ] }); if (maxFailed > 0) { ws.addConditionalFormatting({ // failed ref: `${colLtr(COL_FAILED)}2:${colLtr(COL_FAILED)}${conclusionTable.rowsCount}`, rules: [ { type: 'cellIs', operator: 'greaterThan', formulae: [maxFailed], style: { fill: bgLightRed }, } as CellIsRuleType ] }); ws.addConditionalFormatting({ // almost failed ref: `${colLtr(COL_FAILED)}2:${colLtr(COL_FAILED)}${conclusionTable.rowsCount}`, rules: [ { type: 'cellIs', operator: 'equal', formulae: [maxFailed], style: { fill: bgLightYellow }, } as CellIsRuleType ] }); if (totalExams) { const minSuccess = (totalExams - maxFailed); ws.addConditionalFormatting({ // failed ref: `${colLtr(COL_SUCCESS)}2:${colLtr(COL_SUCCESS)}${conclusionTable.rowsCount}`, rules: [ { type: 'cellIs', operator: 'greaterThan', formulae: [minSuccess - 1], style: { fill: bgLightGreen }, } as CellIsRuleType ] }); } } ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 1, topLeftCell: 'B2', activeCell: 'B2' }]; }) } export async function readManualConclusion(options: OptionValues) { if (!options.manualConclusionFile) { verb(`No manual conclusion file specified, skipped.`) return; } const conclusionFile = generateFileName(options.manualConclusionFile, { stdInFolder: options.stdInFolder, resultsDir: options.resultsDir }); if (! await fileExists(conclusionFile, 'conclusionFile')) { verb(`No manual conclusion file "${conclusionFile}" found, skipped.`); return undefined; } log(`Reading manual conclusion from ${conclusionFile}`) const conclusionFileContent = await fs.promises.readFile(conclusionFile, "utf-8"); // TODO: PATH const conclusionSchema = parse(conclusionFileContent) as unknown as ManualConclusion; return conclusionSchema; } export function checkManualConclusion(manualConclusion: ManualConclusion | undefined) { if (!manualConclusion) { return; } manualConclusion.conclusions.forEach(conclusion => { if (!conclusion.userName) { throw new Error(`Error in manual conclusion: No userName found in conclusion.`); } if (conclusion.totalGrading > 5 || conclusion.totalGrading < 0) { throw new Error(`Error in manual conclusion: totalGrading > 5 (or less 0) for ${conclusion.userName}.`); } if (conclusion.totalGrading >= 1 && !conclusion.gradingReason) { throw new Error(`Error in manual conclusion: totalGrading defined but gradingReason missing for ${conclusion.userName}.`) } if (!conclusion.totalGrading && conclusion.gradingReason) { throw new Error(`Error in manual conclusion: totalGrading not defined but gradingReason found for ${conclusion.userName}.`) } }); };