UNPKG

grading

Version:

Grading of student submissions, in particular programming tests.

347 lines (290 loc) 13.3 kB
import { OptionValues } from "commander"; import fs from "fs"; import { readCSV } from "../csv"; import { ensureFileExists, rmDir, zip } from "../fsUtil"; import { readGradingSchema } from "../grade/gradingschema"; import { error, generateFileName, log, retrieveOriginalMoodleFile, verb, verbosity, warn } from "./cliUtil"; import { AllExamResults, calculateConclusions, checkManualConclusion, Conclusion, normalizeAliases, readAllResults, readManualConclusion, retrieveResultCSVFilenames } from "./cmdConclude"; import { prepareLatexFiles, runLatex } from "./cmdPDF"; import { compareNumberAware } from "./compareNumberAware"; export async function cmdSummary(options: OptionValues, runInternally = false) { verbosity(options); const resultsDir: string = options.resultsDir; const encoding: string = options.encoding; const resultCSVDelimiter: string = options.resultCSVDelimiter; const selectBest: number = options.selectBest; const totalExams: number = options.totalExams; const maxFailed: number = options.maxFailed; const noFinalGradeBefore: string = options.noFinalGradeBefore; const thisPartDescription = options.thisPartDescription; const latex = options.latex; const schemaFile = options.gradingSchemaFile; const summariesLatexFragmentFile = options.summariesLatexFragmentFile; const summaryLatexMainFile = options.summaryLatexMainFile; const latexMainFolderCopy = options.latexMainFolderCopy; const pdfSummariesDir = options.pdfSummariesDir; const workDir = options.workDir; const dry = options.dry; const latexConsole = options.latexConsole const noSummaryPDFZip = !!options.noSummaryPDFZip; const summaryPDFZipFile = options.summaryPDFZipFile; const clean = !!options.clean; let max = Number.parseInt(options.max); try { // Some prequisits const moodleFile = await retrieveOriginalMoodleFile("", options); await ensureFileExists(moodleFile, "Check MoodleCSV setting."); const readable = fs.createReadStream(moodleFile, "utf-8"); const moodleGradingTable = await readCSV(readable); const nameToLatestSubmissionID = new Map<string, string>(); for (let row = 2; row <= moodleGradingTable.rowsCount; row++) { // row 1 is header const longID = moodleGradingTable.at("ID", row); if (!longID.startsWith("Teilnehmer/in")) { throw new Error(`Invalid ID in Moodle file: ${longID}, row ${row}`); } const submissionID = longID.slice("Teilnehmer/in".length); const studentName = moodleGradingTable.at("Vollständiger Name", row); nameToLatestSubmissionID.set(studentName, submissionID); } const gradingSchema = await readGradingSchema(schemaFile); // Create Conclusion const allResultCSVs = await retrieveResultCSVFilenames(resultsDir); 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 } = calculateConclusions(allExamResults, totalExams, selectBest, maxFailed, noFinalGradeBefore, manualConclusion); // Write Latex Fragments await createSummaryLatexFragment( { courseInfo: { date: new Date().toLocaleDateString("de-DE"), course: gradingSchema.course, term: gradingSchema.term, totalExams, selectBest, minPassed: totalExams - maxFailed }, thisPartDescription, conclusions, nameToLatestSubmissionID, summariesLatexFragmentFile, dry } ); // Create PDFs if (!dry) { const { latexMainLatex, latexMainPDF, latexMainWOExt } = prepareLatexFiles(summaryLatexMainFile, workDir, summariesLatexFragmentFile,latexMainFolderCopy); let count = 0; for (let row = 2; row <= moodleGradingTable.rowsCount; row++) { // row 1 is header const longID = moodleGradingTable.at("ID", row); if (!longID.startsWith("Teilnehmer/in")) { throw new Error(`Invalid ID in Moodle file: ${longID}, row ${row}`); } const submissionID = longID.slice("Teilnehmer/in".length); const studentName = moodleGradingTable.at("Vollständiger Name", row); // const studentEmail = moodleGradingTable.at("E-Mail-Adresse", row); const conclusion = conclusions.find(c => c.name === studentName); if (conclusion?.finalGrade==="-") { continue; } if (conclusion && conclusion.examGrades.some(eg => compareNumberAware(eg.exam, noFinalGradeBefore) >= 0)) { count++; const subDir = `${studentName}_${submissionID}_assignsubmission_file`; await runLatex(subDir, latexMainLatex, pdfSummariesDir, latex, workDir, latexMainPDF, latexMainWOExt, latexConsole, options); if (count >= max && max > 0) { break; } } } log(`Created ${count} PDF summaries`) } else { log(`Would create PDF summaries, skipped in dry mode.`); } if (!noSummaryPDFZip && !dry) { const fileName = generateFileName(summaryPDFZipFile, { dateTime: Date.now() }); await zip(pdfSummariesDir, fileName); log(`Created zip file to upload ${fileName}`) if (clean) { await rmDir(pdfSummariesDir); } } } catch (err) { error(err); program.error(String(err)); } if (!runInternally) { log(`${SEP}\nDone`); } }; function numberToGrading(grading: number): string { return grading.toFixed(1).replace('.', ','); } function weightToPercentage(weight: number): string { return (weight * 100).toFixed(0); } type CourseInfo = { date: string; course: string; term: string; totalExams: number; selectBest: number; minPassed: number; } interface ToSummaryLatexArgs { courseInfo: CourseInfo; thisPartDescription: string; conclusions: Conclusion[]; nameToLatestSubmissionID: Map<string, string>; } interface CreateSummaryLatexFragmentArgs extends ToSummaryLatexArgs { summariesLatexFragmentFile: string; dry: boolean; } async function createSummaryLatexFragment( { courseInfo, thisPartDescription, conclusions, nameToLatestSubmissionID, summariesLatexFragmentFile, dry }: CreateSummaryLatexFragmentArgs ) { const latexString = toLatex({ courseInfo, thisPartDescription, conclusions, nameToLatestSubmissionID }); if (dry) { log(`Would write latex fragment "${summariesLatexFragmentFile}" (${(latexString.length / 1024).toFixed(2)} kB), skipped in dry mode.`); return; } else { log(`Write latex fragment "${summariesLatexFragmentFile}".`); await fs.promises.writeFile(summariesLatexFragmentFile, latexString); } } function toLatex({ courseInfo, thisPartDescription, conclusions, nameToLatestSubmissionID }: ToSummaryLatexArgs) { let outstr = ""; let indent = 0; const def = (name: string, value: any) => { outstr += `${" ".repeat(indent)}\\def\\${name}{${value}}\n`; } const outLn = (...text: any[]) => { outstr += ind(); outstr += text.map(t => "" + t).map(s => s.split('\n').join("\n" + ind())).join(""); outstr += '\n'; } const ind = () => { return "".padStart(indent * 4, " ") } const block = (start: string, content: () => void, end: string) => { outLn(start); indent++; content(); indent--; outLn(end); } const examNames = [...new Set(conclusions.map(c => c.examGrades).flat().map(examGrade => examGrade.exam))].sort((a, b) => compareNumberAware(a, b)); if (examNames.length !== courseInfo.totalExams) { warn(`Number of exams in conclusions does not match total exams: ${examNames.length} vs. ${courseInfo.totalExams}`); } const examPrefix = commonPrefix(examNames); outLn('% LaTeX fragment, generated by grading tool, cf. grading summary'); outLn('% --------------------'); outLn('\\newcommand{\\ifequals}[3]{\\ifthenelse{\\equal{#1}{#2}}{#3}{}}'); outLn('%--------------------------------------'); outLn('% Course data'); outLn('%--------------------------------------'); def("summaryDate", courseInfo.date); def("summaryCourse", courseInfo.course); def("summaryTerm", courseInfo.term); def("summaryTotalExams", courseInfo.totalExams); def("summarySelectBest", courseInfo.selectBest); def("summaryMinPassed", courseInfo.minPassed); def("summaryThisTeilleistung", thisPartDescription); outLn('%--------------------------------------'); outLn('% Student data'); outLn('%--------------------------------------'); for (const conclusion of conclusions) { const submissionID = nameToLatestSubmissionID.get(conclusion.name); if (!submissionID) { throw new Error(`No submission ID found for ${conclusion.name}`); } block(`\\ifequals{\\submissionid}{${submissionID}}{`, () => { const gradingForThisPart = conclusion.finalGrade; if (!gradingForThisPart) { throw new Error(`No grading for ${conclusion.name} for this part (${thisPartDescription}) found`); } def("ergName", conclusion.name); def("ergSuccessful", conclusion.successful); def("ergGradingThisPart", gradingForThisPart); block("\\def\\ergConclusion{", () => { const examColumns = "|c".repeat(examNames.length); const examGrades = conclusion.examGrades; block(`\\begin{center}\\begin{tabular}{l${examColumns}}`, () => { outLn(`\\textbf{${examPrefix}} ${examNames.map(name => " &" + name.slice(examPrefix.length)).join('')} \\\\`); outLn(`\\textbf{Abgegeben} ${examNames.map(examName => examGrades.some(eg => eg.exam === examName) ? " & \\ding{52}" : " & \\ding{56}").join('')} \\\\ \\hline`); let countedMissing = 0; outLn(`\\textbf{Note} ${examNames.map(examName => { const examGrade = examGrades.find(eg => eg.exam === examName); if (!examGrade) { countedMissing++; if (countedMissing <= conclusion.missing) { return ` & 5,0`; } else { return ` & {\\color{gray}5,0}`; } } else { if (examGrade.selected) { return ` & ${numberToGrading(examGrade.grade)}`; } else { return ` & {\\color{gray}${numberToGrading(examGrade.grade)}}`; } } }).join('')} \\\\ `); countedMissing = 0; outLn(`\\textbf{Gezählt} ${examNames.map(examName => { const examGrade = examGrades.find(eg => eg.exam === examName); if (!examGrade) { countedMissing++; if (countedMissing <= conclusion.missing) { return ` & \\ding{52}`; } else { return ` & -- `; } } else { if (examGrade.selected) { return ` & \\ding{52}`; } else { return ` & --`; } } }).join('')} \\\\ `); }, `\\end{tabular}\\end{center}`); }, "}"); }, '}'); } return outstr } function commonPrefix(names: string[]) { let prefix = names[0]; for (let i = 1; i < names.length; i++) { const name = names[i]; while (prefix.length > 0) { if (name.startsWith(prefix)) { break; } prefix = prefix.slice(0, -1); } if (prefix.length === 0) { return ""; } } return prefix; }