diff --git a/devU-api/package-lock.json b/devU-api/package-lock.json index 927af045..b4be152a 100644 --- a/devU-api/package-lock.json +++ b/devU-api/package-lock.json @@ -21,6 +21,7 @@ "minio": "^7.0.18", "morgan": "^1.10.0", "multer": "^1.4.2", + "node-fetch": "^2.7.0", "passport": "^0.6.0", "passport-saml": "^3.2.2", "pg": "^8.4.0", @@ -43,6 +44,7 @@ "@types/morgan": "^1.9.2", "@types/multer": "^1.4.7", "@types/node": "^15.12.2", + "@types/node-fetch": "^2.6.11", "@types/passport": "^1.0.6", "@types/passport-strategy": "^0.2.35", "@types/swagger-jsdoc": "^6.0.0", @@ -2149,6 +2151,30 @@ "integrity": "sha512-dvMUE/m2LbXPwlvVuzCyslTEtQ2ZwuuFClDrOQ6mp2CenCg971719PTILZ4I6bTP27xfFFc+o7x2TkLuun/MPw==", "dev": true }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -9271,6 +9297,44 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/devU-api/package.json b/devU-api/package.json index b0379a50..1bbb51d5 100644 --- a/devU-api/package.json +++ b/devU-api/package.json @@ -42,6 +42,7 @@ "@types/morgan": "^1.9.2", "@types/multer": "^1.4.7", "@types/node": "^15.12.2", + "@types/node-fetch": "^2.6.11", "@types/passport": "^1.0.6", "@types/passport-strategy": "^0.2.35", "@types/swagger-jsdoc": "^6.0.0", @@ -61,6 +62,7 @@ "body-parser": "^1.19.0", "colors": "^1.4.0", "config": "^3.3.6", + "node-fetch": "^2.7.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "devu-shared-modules": "./devu-shared-modules", @@ -71,6 +73,7 @@ "minio": "^7.0.18", "morgan": "^1.10.0", "multer": "^1.4.2", + "node-fetch": "^2.7.0", "passport": "^0.6.0", "passport-saml": "^3.2.2", "pg": "^8.4.0", diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts index cf200a89..c882224d 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts @@ -80,12 +80,12 @@ export async function update( containerAutoGrader.graderFile = filename } - if (makefileInputFile) { - const bucket: string = 'makefiles' - const makefileFilename: string = generateFilename(makefileInputFile.originalname, userId) - await filesUpload(bucket, makefileInputFile, containerAutoGrader, makefileFilename, userId) - containerAutoGrader.makefileFile = makefileFilename - } + if (makefileInputFile) { + const bucket: string = 'makefiles' + const makefileFilename: string = generateFilename(makefileInputFile.originalname, userId) + await filesUpload(bucket, makefileInputFile, containerAutoGrader, makefileFilename, userId) + containerAutoGrader.makefileFile = makefileFilename + } const { id, assignmentId, graderFile, makefileFile, autogradingImage, timeout } = containerAutoGrader return await connect().update(id, { assignmentId, graderFile, makefileFile, autogradingImage, timeout }) @@ -110,10 +110,9 @@ export async function getGraderObjectByAssignmentId(assignmentId: number) { return await connect().find({ assignmentId: assignmentId, deletedAt: IsNull() }) } -export async function listByAssignmentId(assignmentId: number) { - if (!assignmentId) throw new Error('Missing AssignmentId') - return await connect().find({ assignmentId: assignmentId, deletedAt: IsNull() }) -} +export async function getGraderByAssignmentId(assignmentId: number){ + const containerAutoGraders = await connect().findOne({ assignmentId: assignmentId, deletedAt: IsNull() }) + if (!containerAutoGraders) return {graderData: null, makefileData: null, autogradingImage: null, timeout: null} export async function getGraderByAssignmentId(assignmentId: number) { const containerAutoGraders = await connect().findOne({ assignmentId: assignmentId, deletedAt: IsNull() }) diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts index 1f264307..8dff9a9d 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts @@ -4,9 +4,8 @@ import validate from '../../middleware/validator/generic.validator' const assignmentId = check('assignmentId').isNumeric() -const graderFile = check('graderFile') - .optional({ nullable: true }) - .custom(({ req }) => { +const graderFile = check('graderFile').optional({ nullable: true }).custom(({req}) => { + const file = req?.files['grader'] if (file !== null) { if (file.size <= 0) { diff --git a/devU-api/src/entities/grader/grader.controller.ts b/devU-api/src/entities/grader/grader.controller.ts index e6f4ceda..572ad669 100644 --- a/devU-api/src/entities/grader/grader.controller.ts +++ b/devU-api/src/entities/grader/grader.controller.ts @@ -2,21 +2,31 @@ import { Request, Response, NextFunction } from 'express' import GraderService from './grader.service' -import { GenericResponse, NotFound } from '../../utils/apiResponse.utils' +import { GenericResponse } from '../../utils/apiResponse.utils' //, NotFound -import { serialize } from './grader.serializer' +//import { serialize } from '../grader/grader.serializer' export async function grade(req: Request, res: Response, next: NextFunction) { - try { - const submissionId = parseInt(req.params.id) - const grade = await GraderService.grade(submissionId) - if (!grade || grade.length === 0) return res.status(404).json(NotFound) - const response = serialize(grade) - - res.status(200).json(response) - } catch (err) { - res.status(400).json(new GenericResponse(err.message)) - } + try { + const submissionId = parseInt(req.params.id) + const response = await GraderService.grade(submissionId) //grade + + res.status(200).json(response) + } catch (err) { + res.status(400).json(new GenericResponse(err.message)) + } +} + +export async function tangoCallback(req: Request, res: Response, next: NextFunction) { + try { + const outputFile = req.params.outputFile + const response = await GraderService.tangoCallback(outputFile) + + res.status(200).json(response) + } catch (err) { + res.status(400).json(new GenericResponse(err.message)) + } + } -export default { grade } +export default { grade, tangoCallback } diff --git a/devU-api/src/entities/grader/grader.router.ts b/devU-api/src/entities/grader/grader.router.ts index 7db825f5..27c2e845 100644 --- a/devU-api/src/entities/grader/grader.router.ts +++ b/devU-api/src/entities/grader/grader.router.ts @@ -4,8 +4,10 @@ import express from 'express' // Middleware //import validator from './grader.validator' import { asInt } from '../../middleware/validator/generic.validator' + import { isAuthorized } from '../../authorization/authorization.middleware' + // Controller import GraderController from './grader.controller' @@ -31,12 +33,36 @@ const Router = express.Router({ mergeParams: true }) * schema: * type: integer */ -Router.post('/:id', isAuthorized('enrolled'), asInt(), GraderController.grade) -// TODO: This one might be tricky. Can every student hit this endpoint for their own submissions? That's probably +Router.post('/:id', asInt(), GraderController.grade) + +/** + * @swagger + * tags: + * - name: Grader callback + * description: + * /grade/callback/{outputFilename}: + * post: + * summary: Not directly called by the user. Tells the API that a container grading job has finished and creates relevant entities from the results. + * tags: + * - Grader + * responses: + * '200': + * description: OK + * parameters: + * - name: outputFilename + * in: path + * required: true + * schema: + * type: string + */ +Router.post('/callback/:outputFile', GraderController.tangoCallback) //Unauthorized route so tango can make callback without needing token + +// TODO: This one might be tricky for authorization. Can every student hit this endpoint for their own submissions? That's probably // the easiest way to handle it. If not, how does this endpoint get hit when they make a submission without // sacrificing RESTfullness? eg. Sometimes we want to make submissions without running the grader so the front end // should have control of this endpoint // -or- do we only let students hit this once per submission. Regrades must be by someone with permission. Then // add a second endpoint that is /regrade + export default Router diff --git a/devU-api/src/entities/grader/grader.service.ts b/devU-api/src/entities/grader/grader.service.ts index 722e0971..d38694d5 100644 --- a/devU-api/src/entities/grader/grader.service.ts +++ b/devU-api/src/entities/grader/grader.service.ts @@ -5,126 +5,247 @@ import nonContainerAutograderService from '../nonContainerAutoGrader/nonContaine import containerAutograderService from '../containerAutoGrader/containerAutoGrader.service' import assignmentProblemService from '../assignmentProblem/assignmentProblem.service' import assignmentScoreService from '../assignmentScore/assignmentScore.service' +import courseService from '../course/course.service' +import { addJob, createCourse, uploadFile, pollJob } from '../../tango/tango.service' -import { SubmissionScore, SubmissionProblemScore, ContainerAutoGrader, AssignmentScore } from 'devu-shared-modules' +import { SubmissionScore, SubmissionProblemScore, AssignmentScore, Submission, NonContainerAutoGrader, AssignmentProblem } from 'devu-shared-modules' import { checkAnswer } from '../nonContainerAutoGrader/nonContainerAutoGrader.grader' import { serialize as serializeNonContainer } from '../nonContainerAutoGrader/nonContainerAutoGrader.serializer' -import { serialize as serializeContainer } from '../containerAutoGrader/containerAutoGrader.serializer' import { serialize as serializeAssignmentScore } from '../assignmentScore/assignmentScore.serializer' +import { serialize as serializeSubmissionScore} from '../submissionScore/submissionScore.serializer' +import { serialize as serializeSubmission } from '../submission/submission.serializer' +import { serialize as serializeAssignmentProblem } from '../assignmentProblem/assignmentProblem.serializer' +import { downloadFile, initializeMinio } from '../../fileStorage' + +async function grade(submissionId: number) { + const submissionModel = await submissionService.retrieve(submissionId) + if (!submissionModel) throw new Error('Submission not found.') + const submission = serializeSubmission(submissionModel) + + const assignmentId = submission.assignmentId + + const content = JSON.parse(submission.content) + const form = content.form + const filepaths: string[] = content.filepaths + + const nonContainerAutograders = (await nonContainerAutograderService.listByAssignmentId(assignmentId)).map(model => serializeNonContainer(model)) + const assignmentProblems = (await assignmentProblemService.list(assignmentId)).map(model => serializeAssignmentProblem(model)) + + let score = 0 + let feedback = '' + + //Run Non-Container Autograders + const ncagResults = await runNonContainerAutograders(form, nonContainerAutograders, assignmentProblems, submissionId) + score += ncagResults.score + feedback += ncagResults.feedback + + //Run Container Autograders + const cagResults = await runContainerAutograders(filepaths, submission, assignmentId) + const jobResponse = cagResults.jobResponse + const containerGrading = cagResults.containerGrading + + //Grading is finished. Create SubmissionScore and AssignmentScore and save to db. + const scoreObj: SubmissionScore = { + submissionId: submissionId, + score: score, //Sum of all SubmissionProblemScore scores + feedback: feedback //Concatination of SubmissionProblemScore feedbacks + } + submissionScoreService.create(scoreObj) + + //If containergrading is true, tangoCallback handles assignmentScore creation + if (containerGrading === false) { + updateAssignmentScore(submission, score) + return {message: `Noncontainer autograding completed successfully`, submissionScore: scoreObj} + } + return {message: `Autograder successfully added job #${jobResponse?.jobId} to the queue with status message ${jobResponse?.statusMsg}`} +} -export async function grade(submissionId: number) { - const submission = await submissionService.retrieve(submissionId) - if (!submission) return null - - const assignmentId = submission.assignmentId +async function runNonContainerAutograders(form: any, nonContainerAutograders: NonContainerAutoGrader[], assignmentProblems: AssignmentProblem[], submissionId: number) { + let score = 0 + let feedback = '' + + for (const question in form) { + const nonContainerGrader = nonContainerAutograders.find(grader => grader.question === question) + const assignmentProblem = assignmentProblems.find(problem => problem.problemName === question) + + if (nonContainerGrader && assignmentProblem) { + const [problemScore, problemFeedback] = checkAnswer(form[question], nonContainerGrader) + score += problemScore + feedback += `${problemFeedback}\n` + + const problemScoreObj: SubmissionProblemScore = { + submissionId: submissionId, + assignmentProblemId: assignmentProblem.id ?? 0, //This should never be undefined, not sure why our types have id set as optional + score: problemScore, + feedback: problemFeedback + } + submissionProblemScoreService.create(problemScoreObj) + } + } + return {score, feedback} +} - const content = JSON.parse(submission.content) - const form = content.form - const filepaths = content.filepaths //Using the field name that was written on the whiteboard for now +async function runContainerAutograders(filepaths: string[], submission: Submission, assignmentId: number) { + let containerGrading = true + let jobResponse = null + + const {graderData, makefileData, autogradingImage, timeout} = await containerAutograderService.getGraderByAssignmentId(assignmentId) + if (!graderData || !makefileData || !autogradingImage || !timeout) { + containerGrading = false + } else { + try { + const bucketName = await courseService.retrieve(submission.courseId).then((course) => { + return course ? (course.number + course.semester + course.id).toLowerCase() : 'submission' + }) + initializeMinio(bucketName) + + const labName = `${bucketName}-${submission.assignmentId}` + const optionFiles = [] + const openResponse = await createCourse(labName) + if (openResponse) { + await uploadFile(labName, graderData, "Graderfile") + await uploadFile(labName, makefileData, "Makefile") + + for (const filepath of filepaths){ + const buffer = await downloadFile(bucketName, filepath) + if (await uploadFile(labName, buffer, filepath)) { + optionFiles.push({localFile: filepath, destFile: filepath}) + } + } + const jobOptions = { + image: autogradingImage, + files: [{localFile: "Graderfile", destFile: "autograde.tar"}, + {localFile: "Makefile", destFile: "Makefile"},] + .concat(optionFiles), + jobName: `${labName}-${submission.id}`, + output_file: `${labName}-${submission.id}-output.txt`, + timeout: timeout, + callback_url: `http://api:3001/grade/callback/${labName}-${submission.id}-output.txt` + } + jobResponse = await addJob(labName, jobOptions) + } + } catch (e: any) { + throw new Error(e) + } + } + return {containerGrading, jobResponse} +} - const nonContainerAutograders = await nonContainerAutograderService.listByAssignmentId(assignmentId) - const containerAutograders = await containerAutograderService.listByAssignmentId(assignmentId) - const assignmentProblems = await assignmentProblemService.list(assignmentId) - let score = 0 - let feedback = '' - let allScores = [] //This is the return value, the serializer parses it into a GraderInfo object for the controller to return +async function tangoCallback(outputFile: string) { + //Output filename consists of 4 sections separated by hyphens. + and () only for visual clarity, not a part of the filename + //(course.number+course.semester+course.id)-(assignment.id)-(submission.id)-(output.txt) + const filenameSplit = outputFile.split('-') + const labName = `${filenameSplit[0]}-${filenameSplit[1]}` + const assignmentId = Number(filenameSplit[1]) + const submissionId = Number(filenameSplit[2]) - //Run Non-Container Autograders - for (const question in form) { - const nonContainerGrader = nonContainerAutograders.find(grader => grader.question === question) - const assignmentProblem = assignmentProblems.find(problem => problem.problemName === question) + try { + const response = await pollJob(labName, outputFile) + if (typeof response !== 'string') throw 'Autograder output file not found' - if (nonContainerGrader && assignmentProblem) { - const [problemScore, problemFeedback] = checkAnswer(form[question], serializeNonContainer(nonContainerGrader)) - score += problemScore - feedback += problemFeedback + '\n' + try { + const splitResponse = response.split(/\r\n|\r|\n/) + var scoresLine = JSON.parse(splitResponse[splitResponse.length - 2]) + } catch { + throw response + } + const scores = scoresLine.scores + + let score = 0 + const assignmentProblems = await assignmentProblemService.list(assignmentId) + const submissionScoreModel = await submissionScoreService.retrieve(submissionId) + const submissionModel = await submissionService.retrieve(submissionId) + if (!submissionModel) throw "Submission not found." + const submission = serializeSubmission(submissionModel) + + for (const question in scores) { + const assignmentProblem = assignmentProblems.find(problem => problem.problemName === question) + if (assignmentProblem) { + const problemScoreObj: SubmissionProblemScore = { + submissionId: submissionId, + assignmentProblemId: assignmentProblem.id, + score: Number(scores[question]), + feedback: `Autograder graded ${assignmentProblem.problemName} for ${Number(scores[question])} points` + } + submissionProblemScoreService.create(problemScoreObj) + score += Number(scores[question]) + } + } + if (submissionScoreModel) { //If noncontainer grading has occured + var submissionScore = serializeSubmissionScore(submissionScoreModel) + submissionScore.score = (submissionScore.score ?? 0) + score + score = submissionScore.score + submissionScore.feedback += `\n${response}` + + await submissionScoreService.update(submissionScore) + } else { //If submission is exclusively container graded + var submissionScore: SubmissionScore = { + submissionId: submissionId, + score: score, //Sum of all SubmissionProblemScore scores + feedback: response //Feedback from Tango + } + await submissionScoreService.create(submissionScore) + } + await updateAssignmentScore(submission, score) - const problemScoreObj: SubmissionProblemScore = { - submissionId: submissionId, - assignmentProblemId: assignmentProblem.id, - score: problemScore, - feedback: problemFeedback, - } - allScores.push(await submissionProblemScoreService.create(problemScoreObj)) + return {submissionScore: submissionScore, outputFile: response} + } catch (e: any) { + callbackFailure(assignmentId, submissionId, e) + throw new Error(e) } - } - - //Run Container Autograders - //Mock functionality, this is not finalized!!!! - if (filepaths) { - for (const filepath of filepaths) { - const containerGrader = containerAutograders.find(grader => grader.autogradingImage === filepath) //PLACEHOLDER, I'm just using autogradingImage temporarily to associate graders to files - - if (containerGrader) { - const gradeResults = await mockContainerCheckAnswer(filepath, serializeContainer(containerGrader)) +} - for (const result of gradeResults) { - const problemScoreObj: SubmissionProblemScore = { - submissionId: submissionId, - assignmentProblemId: 1, //PLACEHOLDER, an assignmentProblem must exist in the db for this to work - score: result.score, - feedback: result.feedback, - } - allScores.push(await submissionProblemScoreService.create(problemScoreObj)) - score += result.score - feedback += result.feedback + '\n' +export async function callbackFailure(assignmentId: number, submissionId: number, file: string) { + const assignmentProblems = await assignmentProblemService.list(assignmentId) + const submissionScoreModel = await submissionScoreService.retrieve(submissionId) + const submissionProblemScoreModels = await submissionProblemScoreService.list(submissionId) + + for (const assignmentProblem of assignmentProblems) { + const submissionProblemScore = submissionProblemScoreModels.find((sps) => sps.assignmentProblemId === assignmentProblem.id) + if (!submissionProblemScore) { //If assignmentProblem hasn't already been graded by noncontainer autograder + const problemScoreObj: SubmissionProblemScore = { + submissionId: submissionId, + assignmentProblemId: assignmentProblem.id, + score: 0, + feedback: 'Autograding failed to complete.' + } + submissionProblemScoreService.create(problemScoreObj) } - } } - } - - //Grading is finished. Create SubmissionScore and AssignmentScore and save to db. - const scoreObj: SubmissionScore = { - submissionId: submissionId, - score: score, //Sum of all SubmissionProblemScore scores - feedback: feedback, //Concatination of SubmissionProblemScore feedbacks - } - allScores.push(await submissionScoreService.create(scoreObj)) - - //PLACEHOLDER AssignmentScore logic. This should be customizable, but for now AssignmentScore will simply equal the latest SubmissionScore - const assignmentScoreModel = await assignmentScoreService.retrieveByUser(submission.assignmentId, submission.userId) - if (assignmentScoreModel) { - //If assignmentScore already exists, update existing entity - const assignmentScore = serializeAssignmentScore(assignmentScoreModel) - assignmentScore.score = score - await assignmentScoreService.update(assignmentScore) - } else { - //Otherwise make a new one - const assignmentScore: AssignmentScore = { - assignmentId: submission.assignmentId, - userId: submission.userId, - score: score, + if (submissionScoreModel) { //If noncontainer grading has occured + var submissionScore = serializeSubmissionScore(submissionScoreModel) + submissionScore.score = (submissionScore.score ?? 0) + submissionScore.feedback += `\n${file}` + + submissionScoreService.update(submissionScore) + } else { //If submission is exclusively container graded + var submissionScore: SubmissionScore = { + submissionId: submissionId, + score: 0, + feedback: file + } + submissionScoreService.create(submissionScore) } - await assignmentScoreService.create(assignmentScore) - } - - return allScores } -//Temporary mock function, delete when the container autograder grading function is written -export async function mockContainerCheckAnswer(file: string, containerAutoGrader: ContainerAutoGrader) { - let gradeResults = [] - - //SubmissionProblemScore 1 - gradeResults.push({ - score: 5, - feedback: 'Grader #' + containerAutoGrader.id + ' graded ' + file + ' problem 1 for 5/5 points', - }) - - //SubmissionProblemScore 2 - gradeResults.push({ - score: 5, - feedback: 'Grader #' + containerAutoGrader.id + ' graded ' + file + ' problem 2 for 5/5 points', - }) - - //SubmissionProblemScore 3 - gradeResults.push({ - score: 10, - feedback: 'Grader #' + containerAutoGrader.id + ' graded ' + file + ' problem 3 for 10/10 points', - }) - - return gradeResults +//Currently just sets assignmentscore to the latest submission. Pulled this function out for easy future modification. +async function updateAssignmentScore(submission: Submission, score: number) { + const assignmentScoreModel = await assignmentScoreService.retrieveByUser(submission.assignmentId, submission.userId) + if (assignmentScoreModel) { //If assignmentScore already exists, update existing entity + const assignmentScore = serializeAssignmentScore(assignmentScoreModel) + assignmentScore.score = score + assignmentScoreService.update(assignmentScore) + + } else { //Otherwise create a new one + const assignmentScore: AssignmentScore = { + assignmentId: submission.assignmentId, + userId: submission.userId, + score: score, + } + assignmentScoreService.create(assignmentScore) + } } -export default { grade } +export default { grade, tangoCallback } + diff --git a/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.grader.ts b/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.grader.ts index c4642f40..6d86c238 100644 --- a/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.grader.ts +++ b/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.grader.ts @@ -11,21 +11,16 @@ export function checkAnswer(studentAnswer: string, nonContainerAutoGrader: NonCo const isMatch: boolean = pattern.test(studentAnswer) if (isMatch) { - return [ - nonContainerAutoGrader.score, - `Grader #${nonContainerAutoGrader.id} graded "${nonContainerAutoGrader.question}" for ${nonContainerAutoGrader.score} points`, - ] + return [nonContainerAutoGrader.score, `Autograder graded "${nonContainerAutoGrader.question}" for ${nonContainerAutoGrader.score} points`] } } else { // if no regex is set use normal string matching if (studentAnswer === nonContainerAutoGrader.correctString) { - return [ - nonContainerAutoGrader.score, - `Grader #${nonContainerAutoGrader.id} graded "${nonContainerAutoGrader.question}" for ${nonContainerAutoGrader.score} points`, - ] + return [nonContainerAutoGrader.score, `Autograder graded "${nonContainerAutoGrader.question}" for ${nonContainerAutoGrader.score} points`] } } // default value to return if all conditions fail to execute // i.e. the answer is incorrect or improperly formatted - return [0, `Grader #${nonContainerAutoGrader.id} graded "${nonContainerAutoGrader.question}" for 0 points`] + return [0, `Autograder graded "${nonContainerAutoGrader.question}" for 0 points`] } + diff --git a/devU-api/src/entities/submission/submission.service.ts b/devU-api/src/entities/submission/submission.service.ts index 77ea5f74..81663e1c 100644 --- a/devU-api/src/entities/submission/submission.service.ts +++ b/devU-api/src/entities/submission/submission.service.ts @@ -34,10 +34,7 @@ export async function create(submission: Submission, file?: Express.Multer.File filename: filename, } const content = JSON.parse(submission.content) - if (!content.filepaths) { - content.filepaths = [] - } - content.filepaths.push(filename) + content.filepaths = [fileModel.filename] submission.content = JSON.stringify(content) await fileConn().save(fileModel) diff --git a/devU-api/src/fileStorage.ts b/devU-api/src/fileStorage.ts index 26a7f30d..af4fe1fd 100644 --- a/devU-api/src/fileStorage.ts +++ b/devU-api/src/fileStorage.ts @@ -63,10 +63,10 @@ export async function downloadFile(bucketName: string, filename: string): Promis minioClient.getObject(bucketName, filename, (err, dataStream) => { if (err) { - reject(new Error('File failed to download from MinIO because' + err.message)) + reject(new Error('File failed to download from MinIO because '+err.message)) } + dataStream.on('data', (chunk:any) => { - dataStream.on('data', (chunk: any) => { fileData.push(chunk) }) diff --git a/devU-api/src/status/status.controller.ts b/devU-api/src/status/status.controller.ts index e3a1c3c8..1c1bff4a 100644 --- a/devU-api/src/status/status.controller.ts +++ b/devU-api/src/status/status.controller.ts @@ -1,5 +1,16 @@ import { Request, Response, NextFunction } from 'express' +import { getInfo, tangoHelloWorld } from '../tango/tango.service' export async function get(req: Request, res: Response, next: NextFunction) { - res.status(200).send() + let tango = false + let tangoStatus; + try { + tango = await tangoHelloWorld() + tangoStatus = await getInfo() + console.log(tangoStatus) + } catch (e) { + console.log('failed to start tango') + console.log(e) + } + res.status(200).send(`Api is online.\n Tango is ${tango ? `Online` : 'Offline'}`) } diff --git a/devU-api/src/tango/tango.service.ts b/devU-api/src/tango/tango.service.ts index 103b91fc..c1fa80a4 100644 --- a/devU-api/src/tango/tango.service.ts +++ b/devU-api/src/tango/tango.service.ts @@ -1,4 +1,6 @@ import './tango.types' +import fetch from "node-fetch"; + const tangoHost = `http://${process.env.TANGO_KEY ?? 'localhost:3000'}` const tangoKey = process.env.TANGO_KEY ?? 'test' @@ -6,21 +8,22 @@ const tangoKey = process.env.TANGO_KEY ?? 'test' // for more info https://docs.autolabproject.com/tango-rest/ /** - * Opens a directory for a given course and lab. - * @param courselab - The combination of the course name and the lab name. + * Opens a directory for a given course. + * @param course - The course name. */ -export async function openDirectory(courselab: string): Promise { - const url = `${tangoHost}/open/${tangoKey}/${courselab}/` +export async function createCourse(course: string): Promise { + const url = `${tangoHost}/open/${tangoKey}/${course}/` const response = await fetch(url, { method: 'GET' }) - return response.ok ? await response.json() : null + return response.ok ? await response.json() as OpenResponse : null } /** - * Uploads a file to the server for a given course and lab. - * @param courselab - The combination of the course name and the lab name. + * Uploads a file to the server for a given course. + * @param course - The course name. * @param fileName - The file name, used to identify the file when uploaded * @param file - The file to be uploaded. */ + export async function uploadFile(courselab: string, file: File, fileName: string): Promise { const url = `${tangoHost}/upload/${tangoKey}/${courselab}/` const formData = new FormData() @@ -30,33 +33,42 @@ export async function uploadFile(courselab: string, file: File, fileName: string } /** - * Adds a job to the server for a given course and lab. - * @param courselab - The combination of the course name and the lab name. + * Adds a job to the server for a given course. + * @param course - The course name. * @param job - The job request object. */ -export async function addJob(courselab: string, job: AddJobRequest): Promise { - const url = `${tangoHost}/addJob/${tangoKey}/${courselab}/` +export async function addJob(course: string, job: AddJobRequest): Promise { + const url = `${tangoHost}/addJob/${tangoKey}/${course}/` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(job), }) - return response.ok ? await response.json() : null + return response.ok ? await response.json() as AddJobResponse : null } /** * Polls the server for the status of a job. - * @param courselab - The combination of the course name and the lab name. + * @param course - The course name. * @param outputFile - The name of the output file. */ -export async function pollJob( - courselab: string, - outputFile: string -): Promise { - const url = `${tangoHost}/poll/${tangoKey}/${courselab}/${outputFile}/` +export async function pollJob(course: string, outputFile: string): Promise { //PollSuccessResponse + const url = `${tangoHost}/poll/${tangoKey}/${course}/${outputFile}/` + const response = await fetch(url, { method: 'GET' }) + + return response.headers.get('Content-Type')?.includes('application/json') + ? (await response.json()) as PollFailureResponse + : (await response.text()) as PollSuccessResponse +} + +/** + * Pings the tango server. + */ +export async function tangoHelloWorld(): Promise { + const url = `${tangoHost}/` const response = await fetch(url, { method: 'GET' }) - const data = await response.json() - return response.ok ? (data as PollSuccessResponse) : (data as PollFailureResponse) + return response.ok + } /** @@ -65,7 +77,7 @@ export async function pollJob( export async function getInfo(): Promise { const url = `${tangoHost}/info/${tangoKey}/` const response = await fetch(url, { method: 'GET' }) - return response.ok ? await response.json() : null + return response.ok ? await response.json() as InfoResponse: null } /** @@ -75,7 +87,7 @@ export async function getInfo(): Promise { export async function getPoolInfo(image: string): Promise { const url = `${tangoHost}/pool/${tangoKey}/${image}/` const response = await fetch(url, { method: 'GET' }) - return response.ok ? await response.json() : null + return response.ok ? await response.json() as Object: null } /** @@ -95,7 +107,7 @@ export async function preallocateInstances( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }) - return response.ok ? await response.json() : null + return response.ok ? await response.json() as PreallocResponse: null } /** diff --git a/devU-api/src/tango/tests/test_files/Makefile b/devU-api/src/tango/tests/test_files/Makefile new file mode 100644 index 00000000..629e3e13 --- /dev/null +++ b/devU-api/src/tango/tests/test_files/Makefile @@ -0,0 +1,4 @@ +all: + tar -xf autograde.tar + chmod +x grader.sh + ./grader.sh \ No newline at end of file diff --git a/devU-api/src/tango/tests/test_files/autograde.tar b/devU-api/src/tango/tests/test_files/autograde.tar new file mode 100644 index 00000000..295b59c0 Binary files /dev/null and b/devU-api/src/tango/tests/test_files/autograde.tar differ diff --git a/devU-api/src/tango/tests/test_files/create_grader.sh b/devU-api/src/tango/tests/test_files/create_grader.sh new file mode 100644 index 00000000..9becea49 --- /dev/null +++ b/devU-api/src/tango/tests/test_files/create_grader.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +tar -cf autograde.tar grader.py grader.sh \ No newline at end of file diff --git a/devU-api/src/tango/tests/test_files/grader.py b/devU-api/src/tango/tests/test_files/grader.py new file mode 100644 index 00000000..d425e66c --- /dev/null +++ b/devU-api/src/tango/tests/test_files/grader.py @@ -0,0 +1,98 @@ +import json + + +def finished_grading(): + print("\nGrader finished") + print(json.dumps({"scores": scores})) + exit(0) + + +print("Grader starting") + +scores = { + "q1": 0, + "q2": 0, + "q3": 0, +} + +negative_tests = { + -9999987654321: 1, + -99: 1, + -32: 1, + -31: 1, + -30: 1, + -29: 1, + -10: 1, + -3: 1, + -2: 1, + -1: 1 +} + +positive_odd_tests = { + 1: 3, + 3: 5, + 5: 7, + 7: 9, + 9: 11, + 11: 13, + 13: 15, + 15: 17, + 101: 103, + 1005: 1007, +} + +positive_even_tests = { + 2: 3, + 4: 5, + 6: 7, + 8: 9, + 10: 11, + 100: 101, + 1000: 1001, + 12345678: 12345679, + 2222: 2223, + 3336: 3337, +} + +try: + from handin import get_next_positive_odd_number +except ImportError: + print("Unable to import get_next_positive_odd_number from handin.py") + finished_grading() + +print("\nTesting negative numbers") +for input_number, expected_result in negative_tests.items(): + result = get_next_positive_odd_number(input_number) + if result == expected_result: + scores["q1"] += 1 + print(f"get_next_positive_odd_number({input_number}) returned {result}, and that's correct! (+1)") + else: + print( + f"get_next_positive_odd_number({input_number}) incorrectly returned {result}, expected {expected_result}. (+0)") + +print("\nTesting positive odd numbers") +for input_number, expected_result in positive_odd_tests.items(): + result = get_next_positive_odd_number(input_number) + if result == expected_result: + scores["q2"] += 1 + print(f"get_next_positive_odd_number({input_number}) returned {result}, and that's correct! (+1)") + else: + print( + f"get_next_positive_odd_number({input_number}) incorrectly returned {result}, expected {expected_result}. (+0)") + +print("\nTesting positive even numbers") +for input_number, expected_result in positive_even_tests.items(): + result = get_next_positive_odd_number(input_number) + if result == expected_result: + scores["q3"] += 1 + print(f"get_next_positive_odd_number({input_number}) returned {result}, and that's correct! (+1)") + else: + print( + f"get_next_positive_odd_number({input_number}) incorrectly returned {result}, expected {expected_result}. (+0)") + +if all([scores["q1"] == 10, scores["q2"] == 10, scores["q3"] == 10]): + print("\nGreat job!") +else: + print("\nTry again.") + +finished_grading() diff --git a/devU-api/src/tango/tests/test_files/grader.sh b/devU-api/src/tango/tests/test_files/grader.sh new file mode 100644 index 00000000..c1aa59e8 --- /dev/null +++ b/devU-api/src/tango/tests/test_files/grader.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +python3 grader.py \ No newline at end of file diff --git a/devU-api/src/tango/tests/test_files/handin.py b/devU-api/src/tango/tests/test_files/handin.py new file mode 100644 index 00000000..978677d4 --- /dev/null +++ b/devU-api/src/tango/tests/test_files/handin.py @@ -0,0 +1,10 @@ +# This is an example of a correct solution. +# It can be helpful to test your grader with the correct solution in the same directory where the student's handin +# will end up. You don't need to include the correct solution in the grader archive. + +def get_next_positive_odd_number(number: int) -> int: + # Given `number`, return the next positive odd number. + # If `number` is negative, return 1. + # If `number` is positive and odd, return `number` + 2. + # If `number` is positive and even, return `number` + 1. + return max(1, number + (number % 2) + 1) diff --git a/devU-api/src/tango/tests/test_files/handin_incorrect1.py b/devU-api/src/tango/tests/test_files/handin_incorrect1.py new file mode 100644 index 00000000..f44068cb --- /dev/null +++ b/devU-api/src/tango/tests/test_files/handin_incorrect1.py @@ -0,0 +1,8 @@ +# This is an example of an incorrect solution. + +def get_next_positive_odd_number(number: int) -> int: + # Given `number`, return the next positive odd number. + # If `number` is negative, return 1. + # If `number` is positive and odd, return `number` + 2. + # If `number` is positive and even, return `number` + 1. + return max(1, number + (number % 2)) diff --git a/devU-api/src/tango/tests/test_files/handin_incorrect2.py b/devU-api/src/tango/tests/test_files/handin_incorrect2.py new file mode 100644 index 00000000..dabbdbdd --- /dev/null +++ b/devU-api/src/tango/tests/test_files/handin_incorrect2.py @@ -0,0 +1,8 @@ +# This is an example of an incorrect solution. + +def get_next_positive_odd_number(number: int) -> int: + # Given `number`, return the next positive odd number. + # If `number` is negative, return 1. + # If `number` is positive and odd, return `number` + 2. + # If `number` is positive and even, return `number` + 1. + return max(1, number + (number % 3) + 1) diff --git a/devU-api/src/tango/tests/test_files/handin_incorrect3.py b/devU-api/src/tango/tests/test_files/handin_incorrect3.py new file mode 100644 index 00000000..995dd7c2 --- /dev/null +++ b/devU-api/src/tango/tests/test_files/handin_incorrect3.py @@ -0,0 +1,8 @@ +# This is an example of an incorrect solution. + +def get_next_positive_odd_number(number: int) -> int: + # Given `number`, return the next positive odd number. + # If `number` is negative, return 1. + # If `number` is positive and odd, return `number` + 2. + # If `number` is positive and even, return `number` + 1. + return min(1, number + (number % 3) + 1) diff --git a/devU-api/src/tango/tests/test_files/handin_incorrect4.py b/devU-api/src/tango/tests/test_files/handin_incorrect4.py new file mode 100644 index 00000000..9ecae225 --- /dev/null +++ b/devU-api/src/tango/tests/test_files/handin_incorrect4.py @@ -0,0 +1,8 @@ +# This is an example of an incorrect solution because the function name is incorrect. + +def incorrect_function_name(number: int) -> int: + # Given `number`, return the next positive odd number. + # If `number` is negative, return 1. + # If `number` is positive and odd, return `number` + 2. + # If `number` is positive and even, return `number` + 1. + return max(1, number + (number % 2) + 1) diff --git a/docker-compose.yml b/docker-compose.yml index 094b2909..66e14693 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - ./devU-api/config:/config api: # Runs the API + container_name: api build: context: . dockerfile: api.Dockerfile @@ -91,11 +92,7 @@ services: environment: - DOCKER_REDIS_HOSTNAME=redis - RESTFUL_KEY=devutangokey -# - DOCKER_DEPLOYMENT - # Path to volumes within the Tango container. Does not need to be modified. -# - DOCKER_VOLUME_PATH - # Modify the below to be the path to volumes on your host machine -# - DOCKER_TANGO_HOST_VOLUME_PATH + - DOCKER_TANGO_HOST_VOLUME_PATH=C:\Users\Keifer\Desktop\Programming\devU\tango_files #TODO add the absolute path to tango_files here before running depends_on: - redis @@ -104,7 +101,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock - ./logs/tango/:/var/log/tango/ - ./logs/tangonginx:/var/log/nginx -# - ./Tango/volumes:/opt/TangoService/Tango/volumes + - ./tango_files:/opt/TangoService/Tango/volumes + # certbot: # container_name: certbot