diff --git a/.dockerignore b/.dockerignore index 2717197c..27e32359 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ /**/node_modules /logs -/devU-api/src/tango/tests/test_files +/devU-api/src/autograders/tango/tests/test_files .env.* \ No newline at end of file diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index 68a8e726..12810142 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -42,9 +42,3 @@ jobs: docker build . -f api.Dockerfile -t $IMAGE_NAME docker push $IMAGE_NAME - - - name: build tango docker - run: | - IMAGE_NAME=ghcr.io/${{ env.repo_url }}/tango:${{ steps.get_branch.outputs.branch_name }} - docker build ./tango -f ./tango/Dockerfile -t $IMAGE_NAME - docker push $IMAGE_NAME diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 6730f75f..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "tango"] - path = tango - url = https://github.com/UB-CSE-IT/Tango diff --git a/client.Dockerfile b/client.Dockerfile index c1075af4..29156905 100644 --- a/client.Dockerfile +++ b/client.Dockerfile @@ -21,4 +21,4 @@ COPY ./devU-client/ . COPY --from=module_builder /tmp/devu-shared-modules ./devu-shared-modules # build frontend during run so that we can modify baseurl via docker envoirment -CMD npm run --silent build-docker && rm -rf /out/* && cp -r /app/dist/* /out +CMD npm run build-docker && rm -rf /out/* && cp -r /app/dist/* /out diff --git a/devU-api/README.md b/devU-api/README.md index cee3b4ac..db3143d1 100644 --- a/devU-api/README.md +++ b/devU-api/README.md @@ -11,104 +11,37 @@ For now the only reason we're including docker is to more easily control the dev ## Running the Project Locally -### Getting Everything Started - -Once you've got these installed, we can build our container and run it - -#### Note to run the postgres container locally using the command below - -You have to modify `devU-api/src/environment.ts` - -change - -`dbHost: (load('database.host') || 'localhost') as string` - -to - -`dbHost: 'localhost'` - -This will probably be fixed in the future but for now the above steps are necessary - -#### Using docker compose - -We use [docker compose profiles](https://docs.docker.com/compose/profiles/) to selectively start services in the main docker-compose when developing. - -Assuming you are in api dir `devU-api`, To start all api services except the api run - -``` -npm run api-services -``` - -To stop the services - -``` -npm run api-services-stop -``` - -Then install dependencies using - -``` -npm install -``` - -Once you've got all the dependencies installed you can run the project via - -``` -npm start -``` - -#### Manually: - -``` -docker run \ - --name typeorm-postgres \ - -p 5432:5432 \ - -e POSTGRES_PASSWORD=password \ - -e POSTGRES_DB=typescript_api \ - -e POSTGRES_USER=typescript_user \ - -d postgres -``` - -Install all node dependencies. All of the database environment variables can change, and can be set as environment variables on your machine if you want to overwrite the defaults - -``` -docker run \ - --name minio \ - -p 9002:9000 \ - -p 9001:9001 \ - -v /tmp/data:/data \ - -e "MINIO_ROOT_USER=typescript_user" \ - -e "MINIO_ROOT_PASSWORD=changeMe" \ - -d minio/minio server /data --console-address ":9001" -``` - -Install all node dependencies. All of the database environment variables can change, and can be set as environment variables on your machine if you want to overwrite the defaults - -``` -npm install -``` - -Run the setup script to create local development auth keys. These are used in local development for signing and authenticating JWTs. - -``` -npm run generate-config -``` - -Run the initial migrations to setup our DB schema - -``` -npm run typeorm -- migration:run -d src/database.ts -``` - -Once you've got all the dependencies installed you can run the project via - -``` -npm start -``` - -By default the project runs at `localhost:3001`, but you can change the port by setting an alternate port by setting the `PORT` environment variable. - -If you're working in vscode, a configuration has been included to allow for debugging. Open up the VS Code Run and Debug section and click `Debug API`. +### Quick Start + +The instructions below assume you are in the api dir `/devU-api/` + +You must have the following tools installed + +* Docker +* Node >= v20 + +Once you've got these installed, + +1. We use [docker compose profiles](https://docs.docker.com/compose/profiles/) + to selectively start services in the main docker-compose when developing. + This starts all required services for the api depends (database, frontend etc.) + ``` + npm run api-services + ``` + + To remove all related containers + ``` + npm run api-services-stop + ``` + +2. Install dependencies using + ``` + npm install + ``` +3. Once you've got all the dependencies installed you can run the project via + ``` + npm run start + ``` ### Convenient Devtools @@ -173,7 +106,7 @@ Here's what you need to know: Here's the basic layout of the application -![control flow of the api](/docs/controlFlow.png 'Control Flow') +![control flow of the api](./docs/controlFlow.png 'Control Flow') Let's take this from the top @@ -218,9 +151,8 @@ When developing if you need to create a new type, This will update the types in `devU-api` and `devU-client` folders. -**Note if the types are not being detected by your IDE** - -**Go to the `devU-api/` and `devU-client/` and run `npm install` in each folder to update the shared modules.** +if the types are not being detected, Go to the `devU-api/` and `devU-client/` +and run `npm install` in each folder to update the shared modules. ### Testing @@ -257,7 +189,7 @@ I wouldn't recommend digging that far down as the of tests should be more human- If the schema needs to be updated, you can do so by updating the models and running ``` -npm run typeorm migration:generate -- -d src/database src/migration/ +npm run create-migration someMeaningfulMigrationName ``` Doing so will attempt to create an auto migration from any changes within the `src/models` directory and add it to `src/migrations`. If an auto migration is generated for you (always check your auto migrations), you can run it with the above migration command diff --git a/devU-api/package.json b/devU-api/package.json index f308058c..b93a8347 100644 --- a/devU-api/package.json +++ b/devU-api/package.json @@ -9,6 +9,7 @@ "create-migrate": "npx typeorm-ts-node-commonjs migration:generate -d src/database.ts", "update-shared": "cd ../devU-shared && npm run build-local && cd ../devU-api && npm i", "typeorm": "typeorm-ts-node-commonjs", + "create-migration": "ts-node scripts/create-migration.ts", "test": "jest --passWithNoTests", "clean": "rimraf build/*", "lint": "tsc", @@ -16,8 +17,8 @@ "format": "prettier --write \"./**/*.{js,ts,json,md}\"", "pre-commit": "lint-staged", "populate-db": "ts-node-dev ./scripts/populate-db.ts", - "tango": "ts-node-dev ./src/tango/tests/tango.endpoint.test.ts", - "api-services": "docker compose -f ../docker-compose.yml --profile dev-api up", + "tango": "ts-node-dev src/autograders/tango/tests/tango.endpoint.test.ts", + "api-services": "docker compose -f ../docker-compose.yml --profile dev-api up --build", "api-services-stop": "docker compose -f ../docker-compose.yml --profile dev-api stop" }, "lint-staged": { @@ -67,9 +68,11 @@ "cors": "^2.8.5", "devu-shared-modules": "./devu-shared-modules", "express": "^4.17.1", + "express-list-endpoints": "^7.1.1", "express-validator": "^6.14.2", "helmet": "^4.6.0", "jsonwebtoken": "^9.0.2", + "leviathan-node-sdk": "https://gitpkg.vercel.app/makeopensource/leviathan/spec/leviathan_node?dev", "minio": "^8.0.0", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", diff --git a/devU-api/scripts/create-migration.ts b/devU-api/scripts/create-migration.ts new file mode 100644 index 00000000..1789a812 --- /dev/null +++ b/devU-api/scripts/create-migration.ts @@ -0,0 +1,26 @@ +import { exec } from 'child_process' + +// Get the migration name from command line arguments +const migrationName = process.argv[2] + +if (!migrationName) { + console.error('Migration name is required!') + console.error('Usage: npm run create-mig MigrationName') + process.exit(1) +} + +const command = `npm run typeorm -- migration:generate -d src/database.ts src/migration/${migrationName}` +console.log(`Executing: ${command}`) + +// Execute the command +exec(command, (error, stdout, stderr) => { + if (stdout) console.log(stdout) + if (stderr) console.error(stderr) + + if (error) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + + console.log('Migration file created successfully!') +}) diff --git a/devU-api/src/autograders/leviathan.service.ts b/devU-api/src/autograders/leviathan.service.ts new file mode 100644 index 00000000..74d16fd6 --- /dev/null +++ b/devU-api/src/autograders/leviathan.service.ts @@ -0,0 +1,115 @@ +import { + CancelJobRequest, + createClient, + createConnectTransport, + DockerFile, + JobLogRequest, + JobService, + LabData, + LabFile, + LabService, + NewJobRequest, + NewLabRequest, + SubmissionFile, + UploadLabFiles, + UploadSubmissionFiles, +} from 'leviathan-node-sdk' + + +const leviUrl = process.env.LEVIATHAN_URL || 'http://localhost:9221' +console.log(`Leviathan url set to ${leviUrl}`) + +const transport = createConnectTransport({ + baseUrl: leviUrl, + httpVersion: '2', +}) + +const jobService = createClient(JobService, transport) +const labService = createClient(LabService, transport) + +export function bufferToBlob(multerFile: Express.Multer.File): Blob { + return new Blob([multerFile.buffer], { type: multerFile.mimetype }) +} + +export async function sendSubmission(labId: bigint, submission: Array) { + const fileId = await UploadSubmissionFiles(leviUrl, submission) + const resp = await jobService.newJob({ + tmpSubmissionFolderId: fileId, + labID: labId, + }) + + return resp.jobId +} + + +export async function createNewLab(lab: LabData, dockerfile: DockerFile, labFiles: Array) { + const fileId = await UploadLabFiles(leviUrl, dockerfile, labFiles) + const resp = await labService.newLab({ + labData: lab, + tmpFolderId: fileId, + }) + + return resp.labId +} + +/** + * streams job status, + * the stream will exit on its own once the job is done, can be cancelled by calling controller.abort() + * @returns a stream and a controller can be used to cancel the stream + * @see waitForJob - for usage example + */ +export function streamJob(jobId: string) { + const controller = new AbortController() + const dataStream = jobService.streamStatus( + { jobId }, + { signal: controller.signal }, + ) + + return { dataStream, controller } +} + + +/** + * gets current job status with logs + */ +export async function getStatus(jobId: string) { + const resp = await jobService.getStatus({ jobId }) + // strip out grpc metadata + const { $unknown, $typeName, ...info } = resp.jobInfo! + const logs = resp.logs + return { info, logs } +} + +/** + * Blocks until job is complete + * @see streamJob - is used under the hood + */ +export async function waitForJob(jobId: string) { + const { dataStream } = streamJob(jobId) + + let jobInfo: { jobId: string; status: string; statusMessage: string } = { + jobId: '', + status: '', + statusMessage: '', + } + let logs: string = '' + + for await (const chunk of dataStream) { + if (!chunk.jobInfo) { + console.warn('Empty job state') + continue + } + + const { $unknown, $typeName, ...rest } = chunk.jobInfo! + console.debug('Job', rest) + + jobInfo = rest + logs = chunk.logs + } + + return { jobInfo, logs } +} + +export async function cancelJob(jobId: string) { + await jobService.cancelJob({ jobId }) +} diff --git a/devU-api/src/entities/assignment/assignment.service.ts b/devU-api/src/entities/assignment/assignment.service.ts index 57efc2cc..b8e89cd1 100644 --- a/devU-api/src/entities/assignment/assignment.service.ts +++ b/devU-api/src/entities/assignment/assignment.service.ts @@ -66,11 +66,12 @@ export async function listByCourse(courseId: number) { export async function listByCourseReleased(courseId: number) { // TODO: filter by start date after current time // const now = new Date(Date.now()) - const allAssignments = await connect().findBy({ courseId: courseId, /*startDate: MoreThanOrEqual(now),*/ deletedAt: IsNull() }) - // console.log("ASSIGNMENTS WITH FILTER: ", allAssignments) - return allAssignments; + return await connect().findBy({ + courseId: courseId, /*startDate: MoreThanOrEqual(now),*/ + deletedAt: IsNull(), + }) } export async function isReleased(id: number) { @@ -87,7 +88,10 @@ export async function isReleased(id: number) { } async function getMaxSubmissionsAndDeadline(id: number) { - return await connect().findOne({ where: { id: id, deletedAt: IsNull() }, select: ['maxSubmissions', 'maxFileSize', 'disableHandins', 'endDate'] }) + return await connect().findOne({ + where: { id: id, deletedAt: IsNull() }, + select: ['maxSubmissions', 'maxFileSize', 'disableHandins', 'endDate'], + }) } async function processFiles(req: Request) { diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.controller.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.controller.ts index 03b595d0..35f9b8ea 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.controller.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.controller.ts @@ -1,17 +1,33 @@ -import { Request, Response, NextFunction } from 'express' - -import ContainerAutoGraderService from './containerAutoGrader.service' +import { NextFunction, Request, Response } from 'express' +import * as ContainerAutoGraderService from './containerAutoGrader.service' import { serialize } from './containerAutoGrader.serializer' - import { GenericResponse, NotFound, Updated } from '../../utils/apiResponse.utils' +import ContainerAutoGraderModel from './containerAutoGrader.model' +import { FileWithMetadata } from './containerAutoGrader.service' +import { UpdateResult } from 'typeorm' + +export interface FileRequest extends Request { + files: { + [fieldname: string]: Express.Multer.File[] + } +} + +export interface ContainerAutoGraderResponse { + model: ContainerAutoGraderModel + files: { + dockerfile: FileWithMetadata + jobFiles: FileWithMetadata[] + } +} export async function get(req: Request, res: Response, next: NextFunction) { try { - const containerAutoGrader = await ContainerAutoGraderService.list() - - res.status(200).json(containerAutoGrader.map(serialize)) - } catch (err) { - next(err) + const id = parseInt(req.params.id) + const result = await ContainerAutoGraderService.retrieve(id) + if (!result) return res.status(404).json(NotFound) + return res.json(result) + } catch (error) { + next(error) } } @@ -22,8 +38,7 @@ export async function detail(req: Request, res: Response, next: NextFunction) { if (!containerAutoGrader) return res.status(404).json(NotFound) - const response = serialize(containerAutoGrader) - + const response = serialize(containerAutoGrader.model) res.status(200).json(response) } catch (err) { next(err) @@ -32,64 +47,89 @@ export async function detail(req: Request, res: Response, next: NextFunction) { export async function getAllByAssignment(req: Request, res: Response, next: NextFunction) { try { - const assignmentId = parseInt(req.params.id) + const assignmentId = parseInt(req.params.assignmentId) if (!assignmentId) return res.status(404).json({ message: 'invalid assignment ID' }) const containerAutoGrader = await ContainerAutoGraderService.getAllGradersByAssignment(assignmentId) - res.status(200).json(containerAutoGrader.map(serialize)) } catch (err) { next(err) } } -/* - * for the post method, I changed how the upload is handled. I am now using fields instead of - * single(for the purpose of uploading grader file and makefile). But I set the makefile to be - * optional, since it is not required. I also added the makefile to the create method in the - * ContainerAutoGraderService. - */ -export async function post(req: Request, res: Response, next: NextFunction) { +export async function post(req: FileRequest, res: Response, next: NextFunction) { try { - if (!req.currentUser?.userId) return res.status(400).json(new GenericResponse('Request requires auth')) - if (!req.files || !('graderFile' in req.files)) { - return res.status(400).json(new GenericResponse('Container Auto Grader requires file upload for grader')) + const dockerfile = req.files['dockerfile'][0] + const jobFiles = req.files['jobFiles'] + const requestBody = { + assignmentId: parseInt(req.body.assignmentId), + timeoutInSeconds: parseInt(req.body.timeoutInSeconds), + memoryLimitMB: req.body.memoryLimitMB ? parseInt(req.body.memoryLimitMB) : 512, + cpuCores: req.body.cpuCores ? parseInt(req.body.cpuCores) : 1, + pidLimit: req.body.pidLimit ? parseInt(req.body.pidLimit) : 100, + entryCmd: req.body.entryCmd, + autolabCompatible: req.body.autolabCompatible === undefined ? true : req.body.autolabCompatible === 'true', } - const graderFile = req.files['graderFile'][0] - const makefile = req.files['makefileFile']?.[0] ?? null - const requestBody = req.body - const userId = req.currentUser?.userId + const userId = req.currentUser!.userId - const containerAutoGrader = await ContainerAutoGraderService.create(requestBody, graderFile, makefile, userId) + const containerAutoGrader = await ContainerAutoGraderService.create(requestBody, dockerfile, jobFiles, userId) const response = serialize(containerAutoGrader) res.status(201).json(response) } catch (err) { if (err instanceof Error) { - res.status(400).json(new GenericResponse(err.message)) + res.status(400).json(new GenericResponse(err.message)) + } else { + next(err) } } } -export async function put(req: Request, res: Response, next: NextFunction) { +export async function put(req: FileRequest, res: Response, next: NextFunction) { try { - if (!req.currentUser?.userId) return res.status(400).json(new GenericResponse('Request requires auth')) - if (req.files && !('graderFile' in req.files) && !('makefileFile' in req.files)) { - return res.status(400).json(new GenericResponse('Uploaded files must be grader or makefile')) + const hasFiles = req.files && ('dockerfile' in req.files || 'jobFiles' in req.files) + if (!hasFiles && Object.keys(req.body).length === 0) { + return res.status(400).json(new GenericResponse('No updates provided')) } - const graderFile = req.files?.['graderFile']?.[0] ?? null - const makefile = req.files?.['makefileFile']?.[0] ?? null - req.body.id = parseInt(req.params.id) - const userId = req.currentUser?.userId - - const results = await ContainerAutoGraderService.update(req.body, graderFile, makefile, userId) - - if (!results.affected) return res.status(404).json(NotFound) + // If job files are being updated, ensure at least one is provided + if (req.files?.['jobFiles']) { + const jobFiles = req.files['jobFiles'] + if (!jobFiles.length) { + return res.status(400).json(new GenericResponse('At least one job file is required')) + } + // Check for empty files + for (const file of jobFiles) { + if (file.size <= 0) { + return res.status(400).json(new GenericResponse('Job file cannot be empty')) + } + } + } - res.status(200).json(Updated) - } catch (err) { - next(err) + const dockerfile = req.files?.dockerfile?.[0] || null + const jobFiles = req.files?.jobFiles || null + + const requestBody = { + id: parseInt(req.params.id), + assignmentId: req.body.assignmentId ? parseInt(req.body.assignmentId) : undefined, + timeoutInSeconds: req.body.timeoutInSeconds ? parseInt(req.body.timeoutInSeconds) : undefined, + memoryLimitMB: req.body.memoryLimitMB ? parseInt(req.body.memoryLimitMB) : undefined, + cpuCores: req.body.cpuCores ? parseInt(req.body.cpuCores) : undefined, + pidLimit: req.body.pidLimit ? parseInt(req.body.pidLimit) : undefined, + entryCmd: req.body.entryCmd, + autolabCompatible: req.body.autolabCompatible === undefined ? true : req.body.autolabCompatible === 'true', + } + const userId = req.currentUser!.userId + + const result: UpdateResult = await ContainerAutoGraderService.update(requestBody, dockerfile, jobFiles, userId) + if (!result.affected) return res.status(404).json(NotFound) + return res.json(Updated) + } catch (error) { + if (error instanceof Error) { + res.status(400).json(new GenericResponse(error.message)) + } else { + next(error) + } } } @@ -97,10 +137,8 @@ export async function _delete(req: Request, res: Response, next: NextFunction) { try { const id = parseInt(req.params.id) const results = await ContainerAutoGraderService._delete(id) - if (!results.affected) return res.status(404).json(NotFound) - - res.status(204).send() + res.status(200).json(Updated) } catch (err) { next(err) } @@ -109,8 +147,8 @@ export async function _delete(req: Request, res: Response, next: NextFunction) { export default { get, detail, + getAllByAssignment, post, put, _delete, - getAllByAssignment, } diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.model.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.model.ts index abd51560..d3b04420 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.model.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.model.ts @@ -7,6 +7,7 @@ import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn, + Check, } from 'typeorm' import AssignmentModel from '../assignment/assignment.model' @@ -22,21 +23,30 @@ export default class ContainerAutoGraderModel { * schemas: * ContainerAutoGrader: * type: object - * required: [assignmentId, autogradingImage, timeout, graderFile] + * required: [assignmentId, dockerfileId, timeoutInSeconds, jobFileIds] * properties: * assignmentId: * type: integer - * autogradingImage: + * dockerfileId: * type: string - * timeout: + * description: MinIO object ID for the uploaded Dockerfile + * jobFileIds: + * type: array + * items: + * type: string + * description: List of MinIO object IDs for uploaded job files + * timeoutInSeconds: * type: integer - * description: Must be a positive integer - * graderFile: - * type: string - * format: binary - * makefileFile: + * description: Must be a positive integer greater than 0 + * pidLimit: + * type: integer + * description: Maximum number of processes allowed in the container + * entryCmd: * type: string - * format: binary + * description: Custom entry command for the container + * autolabCompatible: + * type: boolean + * description: Whether the container is compatible with autolab mode */ @PrimaryGeneratedColumn() @@ -56,16 +66,28 @@ export default class ContainerAutoGraderModel { @ManyToOne(() => AssignmentModel) assignmentId: number - @Column({ name: 'grader_filename', length: 128 }) - graderFile: string + @Column({ name: 'dockerfile_id', length: 512 }) + dockerfileId: string + + @Column({ name: 'job_file_ids', type: 'jsonb', nullable: false, default: [] }) + jobFileIds: string[] + + @Column({ name: 'timeout_in_seconds' }) + @Check('timeout_in_seconds > 0') + timeoutInSeconds: number + + @Column({ name: 'memory_limit_mb', default: 512 }) + memoryLimitMB: number + + @Column({ name: 'cpu_cores', default: 1 }) + cpuCores: number - @Column({ name: 'makefile_filename', type: 'text', nullable: true }) - makefileFile: string | null + @Column({ name: 'pid_limit', default: 100 }) + pidLimit: number - @Column({ name: 'autograding_image' }) - autogradingImage: string + @Column({ name: 'entry_cmd', nullable: true }) + entryCmd?: string - // timeout should be positive integer - @Column({ name: 'timeout' }) - timeout: number + @Column({ name: 'autolab_compatible', default: true }) + autolabCompatible: boolean } diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.router.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.router.ts index c6a4f954..b086d472 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.router.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.router.ts @@ -4,85 +4,132 @@ import multer from 'multer' import validator from './containerAutoGrader.validator' import { asInt } from '../../middleware/validator/generic.validator' import { isAuthorized } from '../../authorization/authorization.middleware' - import ContainerAutoGraderController from './containerAutoGrader.controller' const Router = express.Router({ mergeParams: true }) const upload = multer() /** - * @swagger - * /course/:courseId/assignment/:assignmentId/container-auto-graders/{id}: + * @openapi + * /course/:courseId/assignment/:assignmentId/container-auto-graders: * get: - * summary: Retrieve a single container auto grader + * summary: Retrieve all container auto graders for an assignment * tags: - * - ContainerAutoGraders + * - Container Auto Graders + * security: + * - bearerAuth: [] * responses: - * '200': + * 200: * description: OK - * parameters: - * - name: id - * in: path - * required: true - * schema: - * type: integer + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/ContainerAutoGrader' */ -Router.get('/:id', isAuthorized('assignmentViewAll'), asInt(), ContainerAutoGraderController.detail) +Router.get('/', isAuthorized('assignmentViewAll'), ContainerAutoGraderController.getAllByAssignment) /** - * @swagger - * /course/:courseId/assignment/:assignmentId/container-auto-graders: + * @openapi + * /course/:courseId/assignment/:assignmentId/container-auto-graders/{id}: * get: - * summary: Retrieve an assignment's container auto grader + * summary: Retrieve a specific container auto grader by ID * tags: - * - ContainerAutoGraders - * responses: - * '200': - * description: OK + * - Container Auto Graders + * security: + * - bearerAuth: [] * parameters: * - name: id * in: path * required: true * schema: * type: integer + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ContainerAutoGrader' */ -Router.get('/assignment/:id', asInt(), ContainerAutoGraderController.getAllByAssignment) +Router.get('/:id', isAuthorized('assignmentViewAll'), asInt(), ContainerAutoGraderController.detail) /** - * @swagger - * /container-auto-graders: + * @openapi * /course/:courseId/assignment/:assignmentId/container-auto-graders: * post: * summary: Create a new container auto grader * tags: - * - ContainerAutoGraders - * responses: - * '200': - * description: OK + * - Container Auto Graders + * security: + * - bearerAuth: [] * requestBody: + * required: true * content: * multipart/form-data: * schema: - * $ref: '#/components/schemas/ContainerAutoGrader' + * type: object + * required: + * - jobFiles + * - timeoutInSeconds + * properties: + * dockerfile: + * type: string + * format: binary + * description: The Dockerfile for the auto grader + * jobFiles: + * type: array + * items: + * type: string + * format: binary + * description: List of job files for the auto grader + * timeoutInSeconds: + * type: integer + * minimum: 1 + * memoryLimitMB: + * type: integer + * minimum: 1 + * default: 512 + * description: Memory limit in megabytes + * cpuCores: + * type: integer + * minimum: 1 + * default: 1 + * description: Number of CPU cores to allocate + * pidLimit: + * type: integer + * minimum: 1 + * default: 100 + * description: Maximum number of processes allowed + * responses: + * 201: + * description: Created + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ContainerAutoGrader' */ Router.post( '/', isAuthorized('assignmentEditAll'), - upload.fields([{ name: 'graderFile' }, { name: 'makefileFile' }]), + upload.fields([ + { name: 'dockerfile', maxCount: 1 }, + { name: 'jobFiles', maxCount: 10 } + ]), validator, - ContainerAutoGraderController.post + ContainerAutoGraderController.post as express.RequestHandler ) /** - * @swagger + * @openapi * /course/:courseId/assignment/:assignmentId/container-auto-graders/{id}: * put: - * summary: Update a container auto grader's grader file and/or makefile + * summary: Update an existing container auto grader * tags: - * - ContainerAutoGraders - * responses: - * '200': - * description: OK + * - Container Auto Graders + * security: + * - bearerAuth: [] * parameters: * - name: id * in: path @@ -90,36 +137,78 @@ Router.post( * schema: * type: integer * requestBody: + * required: true * content: * multipart/form-data: * schema: - * $ref: '#/components/schemas/ContainerAutoGrader' + * type: object + * properties: + * dockerfile: + * type: string + * format: binary + * description: The Dockerfile for the auto grader + * jobFiles: + * type: array + * items: + * type: string + * format: binary + * description: List of job files for the auto grader + * timeoutInSeconds: + * type: integer + * minimum: 1 + * memoryLimitMB: + * type: integer + * minimum: 1 + * default: 512 + * description: Memory limit in megabytes + * cpuCores: + * type: integer + * minimum: 1 + * default: 1 + * description: Number of CPU cores to allocate + * pidLimit: + * type: integer + * minimum: 1 + * default: 100 + * description: Maximum number of processes allowed + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ContainerAutoGrader' */ Router.put( '/:id', isAuthorized('assignmentEditAll'), asInt(), - upload.fields([{ name: 'graderFile' }, { name: 'makefileFile' }]), + upload.fields([ + { name: 'dockerfile', maxCount: 1 }, + { name: 'jobFiles', maxCount: 10 } + ]), validator, - ContainerAutoGraderController.put + ContainerAutoGraderController.put as express.RequestHandler ) /** - * @swagger + * @openapi * /course/:courseId/assignment/:assignmentId/container-auto-graders/{id}: * delete: * summary: Delete a container auto grader * tags: - * - ContainerAutoGraders - * responses: - * '200': - * description: OK + * - Container Auto Graders + * security: + * - bearerAuth: [] * parameters: * - name: id * in: path * required: true * schema: * type: integer + * responses: + * 200: + * description: OK */ Router.delete('/:id', isAuthorized('assignmentEditAll'), asInt(), ContainerAutoGraderController._delete) diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.serializer.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.serializer.ts index 856f2e27..9afa3886 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.serializer.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.serializer.ts @@ -6,11 +6,15 @@ export function serialize(containerAutoGrader: ContainerAutoGraderModel): Contai return { id: containerAutoGrader.id, assignmentId: containerAutoGrader.assignmentId, - graderFile: containerAutoGrader.graderFile, - makefileFile: containerAutoGrader.makefileFile, - autogradingImage: containerAutoGrader.autogradingImage, - timeout: containerAutoGrader.timeout, createdAt: containerAutoGrader.createdAt.toISOString(), updatedAt: containerAutoGrader.updatedAt.toISOString(), + cpuCores: containerAutoGrader.cpuCores, + memoryLimitMB: containerAutoGrader.memoryLimitMB, + pidLimit: containerAutoGrader.pidLimit, + timeout: containerAutoGrader.timeoutInSeconds, + dockerfileId: containerAutoGrader.dockerfileId, + graderFileIds: containerAutoGrader.jobFileIds, + entryCommand: containerAutoGrader.entryCmd ?? '', + autolabCompatible: containerAutoGrader.autolabCompatible, } } diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts index b0f74f90..a3e11836 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.service.ts @@ -1,129 +1,264 @@ -import { IsNull } from 'typeorm' -import { dataSource } from '../../database' - -import { ContainerAutoGrader, FileUpload } from 'devu-shared-modules' - +import { IsNull, UpdateResult } from 'typeorm' import ContainerAutoGraderModel from './containerAutoGrader.model' -import FileModel from '../../fileUpload/fileUpload.model' - -import { downloadFile, uploadFile } from '../../fileStorage' +import { dataSource } from '../../database' +import { downloadFile, initializeMinio, uploadFile } from '../../fileStorage' import { generateFilename } from '../../utils/fileUpload.utils' +import fs from 'fs' +import path from 'path' +import FileModel from '../../fileUpload/fileUpload.model' +import { Blob } from 'node:buffer' const connect = () => dataSource.getRepository(ContainerAutoGraderModel) const fileConn = () => dataSource.getRepository(FileModel) -async function filesUpload( +// Load default Dockerfile +const defaultDockerfile = fs.readFileSync( + path.join(__dirname, 'defaultDockerfile.txt'), + 'utf8', +) + +export interface FileWithMetadata { + originalName: string + blob: Buffer + contentType: string +} + +export interface ContainerAutoGraderWithFiles { + model: ContainerAutoGraderModel + files: { + dockerfile: FileWithMetadata + jobFiles: FileWithMetadata[] + } +} + +async function uploadToMinIO( bucket: string, file: Express.Multer.File, - containerAutoGrader: ContainerAutoGrader, - filename: string, - userId: number -) { - const Etag: string = await uploadFile(bucket, file, filename) - const assignmentId = containerAutoGrader.assignmentId - - const fileModel: FileUpload = { - etags: Etag, + assignmentId: number, + userId: number, +): Promise { + await initializeMinio(bucket) + + const filename = generateFilename(file.originalname, userId) + const etag = await uploadFile(bucket, file, filename) + const fileModel = { + etag: etag, fieldName: bucket, - originalName: file.originalname, + name: file.originalname, + type: file.mimetype, filename: filename, assignmentId: assignmentId, + courseId: 1, // TODO: Update this when course management is implemented + userId: userId, } - //TODO: This is a temporary fix to get the function to pass. CourseId should be modified in the future - fileModel.courseId = 1 - fileModel.userId = userId await fileConn().save(fileModel) + return filename +} - return Etag +async function uploadDefaultDockerfile(assignmentId: number, userId: number): Promise { + // Create a buffer from the default Dockerfile content + const buffer = Buffer.from(defaultDockerfile) + + // Create a mock file object that matches Express.Multer.File interface + const mockFile: Express.Multer.File = { + fieldname: 'dockerfile', + originalname: 'Dockerfile', + mimetype: 'text/plain', + filename: 'Dockerfile', + buffer, + size: buffer.length, + stream: null as any, + encoding: '', + destination: '', + path: '', + } + + // Upload the default Dockerfile to MinIO + return await uploadToMinIO('dockerfiles', mockFile, assignmentId, userId) +} + +async function getFileWithMetadata(bucket: string, fileId: string): Promise { + // Get file metadata from database + const fileMetadata = await fileConn().findOneByOrFail({ filename: fileId }) + + // Download file content + const blob = await downloadFile(bucket, fileId) + + return { + originalName: fileMetadata.name, + blob, + contentType: fileMetadata.type || 'application/octet-stream', + } } export async function create( - containerAutoGrader: ContainerAutoGrader, - graderInputFile: Express.Multer.File, - makefileInputFile: Express.Multer.File | null = null, - userId: number -) { - const existingContainerAutoGrader = await connect().findOneBy({ - assignmentId: containerAutoGrader.assignmentId, + requestBody: Pick, + dockerfileInput: Express.Multer.File | null, + jobFileInputs: Express.Multer.File[], + userId: number, +): Promise { + // Upload Dockerfile to MinIO (use default if none provided) + const dockerfileId = dockerfileInput + ? await uploadToMinIO('dockerfiles', dockerfileInput, requestBody.assignmentId, userId) + : await uploadDefaultDockerfile(requestBody.assignmentId, userId) + + // Upload all job files to MinIO + const jobFileIds = await Promise.all( + jobFileInputs.map(jobFile => uploadToMinIO('jobfiles', jobFile, requestBody.assignmentId, userId)), + ) + + // Create container auto grader + const newContainerAutoGrader = { + assignmentId: requestBody.assignmentId, + dockerfileId: dockerfileId, + jobFileIds: jobFileIds, + timeoutInSeconds: requestBody.timeoutInSeconds, + memoryLimitMB: requestBody.memoryLimitMB, + cpuCores: requestBody.cpuCores, + pidLimit: requestBody.pidLimit, + entryCmd: requestBody.entryCmd, + autolabCompatible: requestBody.autolabCompatible ?? true, + } + + return await connect().save(newContainerAutoGrader) +} + +export async function update( + requestBody: Partial, + dockerfileInput: Express.Multer.File | null, + jobFileInputs: Express.Multer.File[] | null, + userId: number, +): Promise { + const containerAutoGrader = await connect().findOneBy({ + id: requestBody.id, deletedAt: IsNull(), }) - if (existingContainerAutoGrader) - throw new Error( - 'Container Auto Grader already exists for this assignment, please update instead of creating a new one' + + if (!containerAutoGrader) { + throw new Error('Container Auto Grader not found') + } + + // Update Dockerfile if provided + if (dockerfileInput) { + containerAutoGrader.dockerfileId = await uploadToMinIO('dockerfiles', dockerfileInput, containerAutoGrader.assignmentId!, userId) + } + + // Update job files if provided + if (jobFileInputs) { + containerAutoGrader.jobFileIds = await Promise.all( + jobFileInputs.map(jobFile => uploadToMinIO('jobfiles', jobFile, containerAutoGrader.assignmentId!, userId)), ) - const bucket: string = 'graders' - const filename: string = generateFilename(graderInputFile.originalname, userId) - await filesUpload(bucket, graderInputFile, containerAutoGrader, filename, userId) - 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 + // Update other fields if provided + if (requestBody.assignmentId) { + containerAutoGrader.assignmentId = requestBody.assignmentId + } + if (requestBody.timeoutInSeconds) { + containerAutoGrader.timeoutInSeconds = requestBody.timeoutInSeconds + } + if (requestBody.memoryLimitMB) { + containerAutoGrader.memoryLimitMB = requestBody.memoryLimitMB + } + if (requestBody.cpuCores) { + containerAutoGrader.cpuCores = requestBody.cpuCores + } + if (requestBody.pidLimit) { + containerAutoGrader.pidLimit = requestBody.pidLimit + } + if (requestBody.entryCmd !== undefined) { + containerAutoGrader.entryCmd = requestBody.entryCmd + } + if (requestBody.autolabCompatible !== undefined) { + containerAutoGrader.autolabCompatible = requestBody.autolabCompatible } - const { id, assignmentId, graderFile, makefileFile, autogradingImage, timeout } = containerAutoGrader - return await connect().save({ id, assignmentId, graderFile, makefileFile, autogradingImage, timeout }) -} + // Ensure there is at least one job file + if (containerAutoGrader.jobFileIds.length === 0) { + throw new Error('Container Auto Grader must have at least one job file') + } -export async function update( - containerAutoGrader: ContainerAutoGrader, - graderInputFile: Express.Multer.File | null = null, - makefileInputFile: Express.Multer.File | null = null, - userId: number -) { - if (!containerAutoGrader.id) throw new Error('Missing Id') - if (graderInputFile) { - const bucket: string = 'graders' - const filename: string = generateFilename(graderInputFile.originalname, userId) - await filesUpload(bucket, graderInputFile, containerAutoGrader, filename, userId) - 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 - } - - const { id, assignmentId, graderFile, makefileFile, autogradingImage, timeout } = containerAutoGrader - return await connect().update(id, { assignmentId, graderFile, makefileFile, autogradingImage, timeout }) + return await connect().update( + { id: requestBody.id }, + { + assignmentId: containerAutoGrader.assignmentId, + timeoutInSeconds: containerAutoGrader.timeoutInSeconds, + dockerfileId: containerAutoGrader.dockerfileId, + jobFileIds: containerAutoGrader.jobFileIds, + memoryLimitMB: containerAutoGrader.memoryLimitMB, + cpuCores: containerAutoGrader.cpuCores, + pidLimit: containerAutoGrader.pidLimit, + entryCmd: containerAutoGrader.entryCmd, + autolabCompatible: containerAutoGrader.autolabCompatible, + updatedAt: new Date(), + }, + ) } -export async function _delete(id: number) { +export async function _delete(id: number): Promise { return await connect().softDelete({ id, deletedAt: IsNull() }) } -export async function retrieve(id: number) { - return await connect().findOneBy({ id, deletedAt: IsNull() }) +export async function retrieve(id: number): Promise { + const containerAutoGrader = await connect().findOneBy({ + id, + deletedAt: IsNull(), + }) + + if (!containerAutoGrader) { + throw new Error('Container Auto Grader not found') + } + + // Download Dockerfile and job files from MinIO with metadata + const dockerfile = await getFileWithMetadata('dockerfiles', containerAutoGrader.dockerfileId) + const jobFiles = await Promise.all( + containerAutoGrader.jobFileIds.map(jobFileId => getFileWithMetadata('jobfiles', jobFileId)), + ) + + return { + model: containerAutoGrader, + files: { + dockerfile, + jobFiles, + }, + } } -export async function list() { - return await connect().findBy({ deletedAt: IsNull() }) +export async function list(): Promise { + return await connect().find({ where: { deletedAt: IsNull() } }) } -export async function getAllGradersByAssignment(assignmentId: number) { - return await connect().findBy({ assignmentId: assignmentId, deletedAt: IsNull() }) +export async function getAllGradersByAssignment(assignmentId: number): Promise { + return await connect().find({ where: { assignmentId, deletedAt: IsNull() } }) } export async function loadGrader(assignmentId: number) { - const containerAutoGraders = await connect().findOneBy({ assignmentId: assignmentId, deletedAt: IsNull() }) - if (!containerAutoGraders) return { graderData: null, makefileData: null, autogradingImage: null, timeout: null } + const containerAutoGrader = await connect().findOneBy({ + assignmentId, + deletedAt: IsNull(), + }) + if (!containerAutoGrader) { + throw new Error('Container Auto Grader not found') + } - const { graderFile, makefileFile, autogradingImage, timeout } = containerAutoGraders - const graderData = await downloadFile('graders', graderFile) - let makefileData + // Get Dockerfile and job files metadata and content from MinIO + const dockerfileData = await getFileWithMetadata('dockerfiles', containerAutoGrader.dockerfileId) + const jobFilesData = await Promise.all( + containerAutoGrader.jobFileIds.map(jobFileId => getFileWithMetadata('jobfiles', jobFileId)), + ) - if (makefileFile) { - makefileData = await downloadFile('makefiles', makefileFile) - } else { - makefileData = await downloadFile('makefiles', 'defaultMakefile') // Put actual default makefile name here + return { + dockerfile: { + blob: new Blob([dockerfileData.blob], { type: dockerfileData.contentType }), + filename: dockerfileData.originalName, + }, + jobFiles: jobFilesData.map(file => ({ + blob: new Blob([file.blob], { type: file.contentType }), + filename: file.originalName, + })), + containerAutoGrader, } - - return { graderData, makefileData, autogradingImage, timeout } } export default { @@ -132,6 +267,6 @@ export default { update, _delete, list, - loadGrader, getAllGradersByAssignment, + loadGrader, } diff --git a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts index 1f264307..0cb44071 100644 --- a/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts +++ b/devU-api/src/entities/containerAutoGrader/containerAutoGrader.validator.ts @@ -1,41 +1,90 @@ import { check } from 'express-validator' - import validate from '../../middleware/validator/generic.validator' const assignmentId = check('assignmentId').isNumeric() -const graderFile = check('graderFile') - .optional({ nullable: true }) - .custom(({ req }) => { - const file = req?.files['grader'] - if (file !== null) { - if (file.size <= 0) { - throw new Error('File is empty') - } - } else { - throw new Error('does not have grader file') +const dockerfile = check('dockerfile') + .custom((value, { req }) => { + // Dockerfile is now optional + if (!req.files || !('dockerfile' in req.files)) { + return true // Will use default Dockerfile } + // If provided, validate it + const file = req.files['dockerfile'][0] + if (!file || file.size <= 0) { + throw new Error('Provided Dockerfile is empty') + } + return true }) - .withMessage('Grader file is required') + .withMessage('If provided, Dockerfile must not be empty') -const makefileFile = check('makefileFile') - .optional({ nullable: true }) - .custom(({ req }) => { - const file = req.files['makefile'] - if (file !== null) { +const jobFiles = check('jobFiles') + .custom((value, { req }) => { + if (!req.files || !('jobFiles' in req.files)) { + throw new Error('Job files are required') + } + const files = req.files['jobFiles'] + if (!files || !files.length) { + throw new Error('At least one job file is required') + } + for (const file of files) { if (file.size <= 0) { - throw new Error('File is empty') + throw new Error('Job file cannot be empty') } } + return true }) + .withMessage('Valid job files are required') -const autogradingImage = check('autogradingImage').isString() - -const timeout = check('timeout') +const timeout = check('timeoutInSeconds') .isNumeric() .custom(value => value > 0) .withMessage('Timeout should be a positive integer') -const validator = [assignmentId, graderFile, makefileFile, autogradingImage, timeout, validate] +const memoryLimit = check('memoryLimitMB') + .optional() + .isNumeric() + .custom(value => value > 0) + .withMessage('Memory limit should be a positive integer') + +const cpuCores = check('cpuCores') + .optional() + .isNumeric() + .custom(value => value > 0) + .withMessage('CPU cores should be a positive integer') + +const pidLimit = check('pidLimit') + .optional() + .isNumeric() + .custom(value => value > 0) + .withMessage('PID limit should be a positive integer') + +const autolabCompatible = check('autolabCompatible') + .optional() + .isBoolean() + .withMessage('autolabCompatible must be a boolean value') + +const entryCmd = check('entryCmd') + .custom((value, { req }) => { + const isautolabCompatible = req.body.autolabCompatible !== false + if (!isautolabCompatible && (!value || value.trim() === '')) { + throw new Error('entryCmd is required when autolabCompatible is false') + } + return true + }) + .withMessage('entryCmd is required when autolabCompatible is false') + +const validator = [ + assignmentId, + dockerfile, + jobFiles, + timeout, + memoryLimit, + cpuCores, + pidLimit, + autolabCompatible, + entryCmd, + validate +] export default validator diff --git a/devU-api/src/entities/containerAutoGrader/defaultDockerfile.txt b/devU-api/src/entities/containerAutoGrader/defaultDockerfile.txt new file mode 100644 index 00000000..bb5ea8e5 --- /dev/null +++ b/devU-api/src/entities/containerAutoGrader/defaultDockerfile.txt @@ -0,0 +1,63 @@ +FROM ubuntu:24.04 +LABEL org.opencontainers.image.authors="Nicholas Myers" + +RUN apt update + +# C++ Setup +RUN apt install -y gcc +RUN apt install -y make +RUN apt install -y build-essential +RUN apt install -y libcunit1-dev libcunit1-doc libcunit1 + +# Python Setup +RUN apt update --fix-missing +RUN DEBIAN_FRONTEND=nointeractive apt install -y \ + python3 \ + python3-pip \ + python3-numpy \ + python3-pandas \ + python3-nose + +# Java Setup +RUN apt update --fix-missing +RUN apt install -y default-jdk + +# Valgrind Setup +RUN apt update +RUN apt install -y valgrind + +# Utility setup +RUN apt install -y unzip + +# NodeJS setup +RUN apt update --fix-missing +RUN apt install -y nodejs +RUN apt install -y npm + +# Install autodriver +WORKDIR /home +RUN useradd autolab +RUN useradd autograde +RUN mkdir autolab autograde output +RUN chown autolab:autolab autolab +RUN chown autolab:autolab output +RUN chown autograde:autograde autograde +RUN apt update && apt install -y sudo +RUN apt install -y git +RUN git clone https://github.com/autolab/Tango.git +WORKDIR Tango/autodriver +RUN make clean && make +RUN cp autodriver /usr/bin/autodriver +RUN chmod +s /usr/bin/autodriver + +# Clean up +WORKDIR /home +RUN apt -y autoremove +RUN rm -rf Tango/ + +# Check installation +RUN ls -l /home +RUN which autodriver +RUN g++ --version +RUN python3 --version +RUN which javac diff --git a/devU-api/src/entities/grader/grader.controller.ts b/devU-api/src/entities/grader/grader.controller.ts index df373c6f..0f7c1502 100644 --- a/devU-api/src/entities/grader/grader.controller.ts +++ b/devU-api/src/entities/grader/grader.controller.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction } from 'express' +import { NextFunction, Request, Response } from 'express' import GraderService from './grader.service' @@ -14,22 +14,9 @@ export async function grade(req: Request, res: Response, next: NextFunction) { res.status(200).json(response) } catch (err) { if (err instanceof Error) { - res.status(400).json(new GenericResponse(err.message)) + 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) { - if (err instanceof Error) { - res.status(400).json(new GenericResponse(err.message)) - } - } -} - -export default { grade, tangoCallback } +export default { grade } diff --git a/devU-api/src/entities/grader/grader.router.ts b/devU-api/src/entities/grader/grader.router.ts index 33783097..2e4ba28a 100644 --- a/devU-api/src/entities/grader/grader.router.ts +++ b/devU-api/src/entities/grader/grader.router.ts @@ -33,28 +33,5 @@ const Router = express.Router() * type: integer */ Router.post('/:id', asInt(), isAuthenticated, /*isAuthorized('enrolled'),*/ GraderController.grade) -// TODO: Add authorization, 'enrolled' was causing issues - -/** - * @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 export default Router diff --git a/devU-api/src/entities/grader/grader.service.ts b/devU-api/src/entities/grader/grader.service.ts index 23237c85..a6ae5c1f 100644 --- a/devU-api/src/entities/grader/grader.service.ts +++ b/devU-api/src/entities/grader/grader.service.ts @@ -6,15 +6,14 @@ import containerAutograderService from '../containerAutoGrader/containerAutoGrad 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, + AssignmentProblem, AssignmentScore, - Submission, NonContainerAutoGrader, - AssignmentProblem, + Submission, + SubmissionProblemScore, + SubmissionScore, } from 'devu-shared-modules' import { checkAnswer } from '../nonContainerAutoGrader/nonContainerAutoGrader.grader' import { serialize as serializeNonContainer } from '../nonContainerAutoGrader/nonContainerAutoGrader.serializer' @@ -23,6 +22,9 @@ import { serialize as serializeSubmissionScore } from '../submissionScore/submis import { serialize as serializeSubmission } from '../submission/submission.serializer' import { serialize as serializeAssignmentProblem } from '../assignmentProblem/assignmentProblem.serializer' import { downloadFile, initializeMinio } from '../../fileStorage' +import { createNewLab, sendSubmission, waitForJob } from '../../autograders/leviathan.service' +import { DockerFile, LabData, LabFile, SubmissionFile } from 'leviathan-node-sdk' +import path from 'path' async function grade(submissionId: number) { const submissionModel = await submissionService.retrieve(submissionId) @@ -36,10 +38,10 @@ async function grade(submissionId: number) { const filepaths: string[] = content.filepaths const nonContainerAutograders = (await nonContainerAutograderService.listByAssignmentId(assignmentId)).map(model => - serializeNonContainer(model) + serializeNonContainer(model), ) const assignmentProblems = (await assignmentProblemService.list(assignmentId)).map(model => - serializeAssignmentProblem(model) + serializeAssignmentProblem(model), ) let score = 0 @@ -51,9 +53,7 @@ async function grade(submissionId: number) { feedback += ncagResults.feedback //Run Container Autograders - const cagResults = await runContainerAutograders(filepaths, submission, assignmentId) - const jobResponse = cagResults.jobResponse - const containerGrading = cagResults.containerGrading + const jobId = await runCagLeviathan(filepaths, submission, assignmentId) //Grading is finished. Create SubmissionScore and AssignmentScore and save to db. const scoreObj: SubmissionScore = { @@ -61,15 +61,17 @@ async function grade(submissionId: number) { score: score, //Sum of all SubmissionProblemScore scores feedback: feedback, //Concatination of SubmissionProblemScore feedbacks } - submissionScoreService.create(scoreObj) + await submissionScoreService.create(scoreObj) - //If containergrading is true, tangoCallback handles assignmentScore creation - if (!containerGrading) { + // todo dumb hack + // for now empty job id implies it is not a containerautograde, + // it also means something went wrong on auto grading which is why it is a dumb hack + if (jobId === '') { await 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}`, + message: `Autograder successfully added job ${jobId} to the queue`, } } @@ -77,7 +79,7 @@ async function runNonContainerAutograders( form: any, nonContainerAutograders: NonContainerAutoGrader[], assignmentProblems: AssignmentProblem[], - submissionId: number + submissionId: number, ) { let score = 0 let feedback = '' @@ -97,79 +99,89 @@ async function runNonContainerAutograders( score: problemScore, feedback: problemFeedback, } - submissionProblemScoreService.create(problemScoreObj) + await submissionProblemScoreService.create(problemScoreObj) } } return { score, feedback } } -async function runContainerAutograders(filepaths: string[], submission: Submission, assignmentId: number) { - let containerGrading = true - let jobResponse = null +export async function runCagLeviathan(filepaths: string[], submission: Submission, assignmentId: number) { + try { + const { dockerfile, jobFiles, containerAutoGrader: graderinfo } = + await containerAutograderService.loadGrader(assignmentId) - const { graderData, makefileData, autogradingImage, timeout } = - await containerAutograderService.loadGrader(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' + const bucketName = await courseService.retrieve(submission.courseId).then(course => { + return course ? (course.number + course.semester + course.id).toLowerCase() : 'submission' + }) + await initializeMinio(bucketName) + + const labName = `${bucketName}-${submission.assignmentId}` + + const labinfo = { + entryCmd: graderinfo.entryCmd ?? '', + limits: { + PidLimit: graderinfo.pidLimit, + CPUCores: graderinfo.cpuCores, + memoryInMb: graderinfo.memoryLimitMB, + }, + labname: labName, + autolabCompatibilityMode: graderinfo.autolabCompatible, + jobTimeoutInSeconds: BigInt(graderinfo.timeoutInSeconds), + } + + const labId = await createNewLab(labinfo, + { + fieldName: 'dockerfile', + filedata: dockerfile.blob, + filename: dockerfile.filename, + }, + jobFiles.map(value => { + fieldName: 'labFiles', + filename: value.filename, + filedata: value.blob, + }), + ) + + const submissionFiles = new Array + + for (const filepath of filepaths) { + const filename = path.basename(filepath) + const buffer = await downloadFile(bucketName, filename) + const blob = new Blob([buffer], { type: 'text/plain' }) + submissionFiles.push({ + fieldName: 'submissionFiles', + filename: filename, + filedata: blob, }) - await 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/course/${submission.courseId}/grade/callback/${labName}-${submission.id}-output.txt`, - } - jobResponse = await addJob(labName, jobOptions) - } - } catch (e: any) { - throw new Error(e) } + + const jobid = await sendSubmission(labId, submissionFiles) + + // process asynchronously + leviathanCallback(jobid, assignmentId, submission.id!).then(value => { + console.log('callback complete') + console.log(value) + }).catch(err => console.error(err)) + + return jobid + } catch (e) { + console.error(e) + return '' } - return { containerGrading, jobResponse } } -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]) +export async function leviathanCallback(jobId: string, assignmentId: number, submissionId: number) { + let submissionScore try { - const response = await pollJob(labName, outputFile) - if (typeof response !== 'string') throw 'Autograder output file not found' - - try { - const splitResponse = response.split(/\r\n|\r|\n/) - var scoresLine = JSON.parse(splitResponse[splitResponse.length - 2]) - } catch { - throw response + const { jobInfo, logs } = await waitForJob(jobId) + console.log(jobInfo) + + if (!(jobInfo.status === 'complete')) { + throw Error(`Job ${jobId} failed to complete: reason: ${jobInfo.statusMessage}`) } - const scores = scoresLine.scores + + const scores = JSON.parse(jobInfo.statusMessage) let score = 0 const assignmentProblems = await assignmentProblemService.list(assignmentId) @@ -187,44 +199,45 @@ async function tangoCallback(outputFile: string) { score: Number(scores[question]), feedback: `Autograder graded ${assignmentProblem.problemName} for ${Number(scores[question])} points`, } - submissionProblemScoreService.create(problemScoreObj) + await submissionProblemScoreService.create(problemScoreObj) score += Number(scores[question]) } } if (submissionScoreModel) { //If noncontainer grading has occured - var submissionScore = serializeSubmissionScore(submissionScoreModel) + submissionScore = serializeSubmissionScore(submissionScoreModel) submissionScore.score = (submissionScore.score ?? 0) + score score = submissionScore.score - submissionScore.feedback += `\n${response}` + submissionScore.feedback += `\n${logs}` await submissionScoreService.update(submissionScore) } else { //If submission is exclusively container graded - var submissionScore: SubmissionScore = { + submissionScore = { submissionId: submissionId, score: score, //Sum of all SubmissionProblemScore scores - feedback: response, //Feedback from Tango + feedback: logs, } await submissionScoreService.create(submissionScore) } await updateAssignmentScore(submission, score) - return { submissionScore: submissionScore, outputFile: response } + return { submissionScore: submissionScore, outputFile: logs } } catch (e: any) { - callbackFailure(assignmentId, submissionId, e) + await callbackFailure(assignmentId, submissionId, e) throw new Error(e) } } export async function callbackFailure(assignmentId: number, submissionId: number, file: string) { + let submissionScore 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 + sps => sps.assignmentProblemId === assignmentProblem.id, ) if (!submissionProblemScore) { //If assignmentProblem hasn't already been graded by noncontainer autograder @@ -234,19 +247,19 @@ export async function callbackFailure(assignmentId: number, submissionId: number score: 0, feedback: 'Autograding failed to complete.', } - submissionProblemScoreService.create(problemScoreObj) + await submissionProblemScoreService.create(problemScoreObj) } } if (submissionScoreModel) { //If noncontainer grading has occured - var submissionScore = serializeSubmissionScore(submissionScoreModel) + 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 = { + submissionScore = { submissionId: submissionId, score: 0, feedback: file, @@ -274,4 +287,52 @@ async function updateAssignmentScore(submission: Submission, score: number) { } } -export default { grade, tangoCallback } +// async function runContainerAutograders(filepaths: string[], submission: Submission, assignmentId: number) { +// let containerGrading = true +// let jobResponse = null +// +// const { graderData, makefileData, autogradingImage, timeout } = +// await containerAutograderService.loadGrader(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' +// }) +// await 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/course/${submission.courseId}/grade/callback/${labName}-${submission.id}-output.txt`, +// } +// jobResponse = await addJob(labName, jobOptions) +// } +// } catch (e: any) { +// throw new Error(e) +// } +// } +// return { containerGrading, jobResponse } +// } + +export default { grade } diff --git a/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.model.ts b/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.model.ts index 0287e51e..8f79626a 100644 --- a/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.model.ts +++ b/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.model.ts @@ -27,6 +27,9 @@ export default class NonContainerAutoGraderModel { * type: integer * question: * type: string + * metadata: + * type: any + * description: this contains a valid json string tha contains info about any arbitrary question type (MCQ, Fill in the blanks etc.) * score: * type: number * correctString: @@ -53,6 +56,9 @@ export default class NonContainerAutoGraderModel { @Column({ name: 'question', length: 128 }) question: string + @Column({ name: 'metadata', type: 'jsonb', nullable: true, default: {} }) + metadata: any // use any since this can be any arbitrary structure + @Column({ name: 'score' }) score: number diff --git a/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.serializer.ts b/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.serializer.ts index 39917542..4db25712 100644 --- a/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.serializer.ts +++ b/devU-api/src/entities/nonContainerAutoGrader/nonContainerAutoGrader.serializer.ts @@ -8,6 +8,7 @@ export function serialize(nonContainerAutoGrader: NonContainerAutoGraderModel): assignmentId: nonContainerAutoGrader.assignmentId, question: nonContainerAutoGrader.question, score: nonContainerAutoGrader.score, + metadata: JSON.stringify(nonContainerAutoGrader.metadata ?? ''), correctString: nonContainerAutoGrader.correctString, isRegex: nonContainerAutoGrader.isRegex, createdAt: nonContainerAutoGrader.createdAt.toISOString(), diff --git a/devU-api/src/entities/submission/submission.service.ts b/devU-api/src/entities/submission/submission.service.ts index 4e21c8ab..f6eb2ec4 100644 --- a/devU-api/src/entities/submission/submission.service.ts +++ b/devU-api/src/entities/submission/submission.service.ts @@ -5,7 +5,7 @@ import SubmissionModel from '../submission/submission.model' import FileModel from '../../fileUpload/fileUpload.model' import CourseModel from '../course/course.model' -import { FileUpload, Submission } from 'devu-shared-modules' +import { Submission } from 'devu-shared-modules' import { uploadFile } from '../../fileStorage' const submissionConn = () => dataSource.getRepository(SubmissionModel) @@ -25,14 +25,15 @@ export async function create(submission: Submission, file?: Express.Multer.File const filename: string = file.originalname const Etag: string = await uploadFile(bucket, file, filename) - const fileModel: FileUpload = { + const fileModel = { userId: submission.userId, assignmentId: submission.assignmentId, courseId: submission.courseId, - etags: Etag, + etag: Etag, fieldName: bucket, - originalName: file.originalname, - filename: filename, + name: filename, + type: 'application/octet-stream', + filename: file.originalname, } const content = JSON.parse(submission.content) if (!content.filepaths) { diff --git a/devU-api/src/entities/webhooks/webhooks.middleware.ts b/devU-api/src/entities/webhooks/webhooks.middleware.ts index 6c40389f..0f27cbfc 100644 --- a/devU-api/src/entities/webhooks/webhooks.middleware.ts +++ b/devU-api/src/entities/webhooks/webhooks.middleware.ts @@ -59,7 +59,7 @@ export function responseInterceptor(req: Request, res: Response, next: NextFunct console.log('Sent webhook successfully') }, ).catch(err => { - console.warn('Error sending webhook', err) + // console.warn('Error sending webhook', err) }) } } diff --git a/devU-api/src/environment.ts b/devU-api/src/environment.ts index 903af00f..eaf9dfa5 100644 --- a/devU-api/src/environment.ts +++ b/devU-api/src/environment.ts @@ -51,12 +51,6 @@ const refreshTokenBuffer = load('auth.jwt.refreshTokenExpirationBufferSeconds') // if it is undefined it is running on dev machine const isDocker = !(process.env.IS_DOCKER === undefined) -if (isDocker && process.env.TANGO_KEY === undefined) { - throw Error( - 'Tango key not found.\nMake sure to set environment variable TANGO_KEY in the api service in docker-compose' - ) -} - const environment = { port, apiUrl, diff --git a/devU-api/src/fileUpload/fileUpload.model.ts b/devU-api/src/fileUpload/fileUpload.model.ts index ccca8ce8..2958d727 100644 --- a/devU-api/src/fileUpload/fileUpload.model.ts +++ b/devU-api/src/fileUpload/fileUpload.model.ts @@ -27,6 +27,21 @@ export default class FileModel { @DeleteDateColumn({ name: 'deleted_at' }) deletedAt?: Date + @Column({ name: 'etag' }) + etag: string + + @Column({ name: 'name' }) + name: string + + @Column({ name: 'type' }) + type: string + + @Column({ name: 'filename', length: 128 }) + filename: string + + @Column({ name: 'bucket', length: 64 }) + fieldName: string + @Column({ name: 'course_id' }) @JoinColumn({ name: 'course_id' }) @ManyToOne(() => CourseModel) @@ -41,10 +56,4 @@ export default class FileModel { @JoinColumn({ name: 'user_id' }) @ManyToOne(() => UserModel) userId: number - - @Column({ name: 'filename', length: 128 }) - filename: string - - @Column({ name: 'bucket', length: 64 }) - fieldName: string } diff --git a/devU-api/src/index.ts b/devU-api/src/index.ts index 92730683..7f75b53c 100644 --- a/devU-api/src/index.ts +++ b/devU-api/src/index.ts @@ -23,17 +23,11 @@ import { responseInterceptor } from './entities/webhooks/webhooks.middleware' const app = express() -initializeMinio() - .then(() => - dataSource.initialize() - .then(() => { - console.log('Data Source has been initialized!') - }) - .catch(err => { - console.error('Error during Data Source initialization', err) - }) - ) - .then(_connection => { +async function main() { + try { + await initializeMinio() + await dataSource.initialize() + app.use(helmet()) app.use(express.urlencoded({ extended: true })) app.use(express.json()) @@ -42,17 +36,19 @@ initializeMinio() app.use(morgan('combined')) app.use(passport.initialize()) - console.log(`Api: ${environment.isDocker ? '' : 'not'} running in docker`) - - app.use(responseInterceptor) - - // Middleware; + console.log(`API${environment.isDocker ? '' : ' not'} running in docker`) + app.use(responseInterceptor) // webhooks app.use('/', router) app.use(errorHandler) app.listen(environment.port, () => - console.log(`API listening at port - ${environment.port}\n - If you are running the full app, the front end should be accessible at http://localhost:9000`) + console.log(`API listening at port - ${environment.port}`), ) - }) - .catch(err => console.log('TypeORM connection error:', err)) + } catch (e: any) { + console.error(`Error during initialization ${e.toString()}`) + } +} + +main().catch((err: any) => { + console.error(err) +}) \ No newline at end of file diff --git a/devU-api/src/migration/1741738490302-addMetadataToNCAG.ts b/devU-api/src/migration/1741738490302-addMetadataToNCAG.ts new file mode 100644 index 00000000..eb1c17c1 --- /dev/null +++ b/devU-api/src/migration/1741738490302-addMetadataToNCAG.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMetadataToNCAG1741738490302 implements MigrationInterface { + name = 'AddMetadataToNCAG1741738490302' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "nonContainerAutoGrader" ADD "metadata" jsonb DEFAULT '{}'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "nonContainerAutoGrader" DROP COLUMN "metadata"`); + } + +} diff --git a/devU-api/src/migration/1743395416522-updateCag.ts b/devU-api/src/migration/1743395416522-updateCag.ts new file mode 100644 index 00000000..44df3fca --- /dev/null +++ b/devU-api/src/migration/1743395416522-updateCag.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateCag1743395416522 implements MigrationInterface { + name = 'UpdateCag1743395416522' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "timeout"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "makefile_filename"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "autograding_image"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "grader_filename"`); + await queryRunner.query(`ALTER TABLE "FilesAuth" ADD "etag" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "FilesAuth" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "FilesAuth" ADD "type" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "dockerfile_id" character varying(512) NOT NULL`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "job_file_ids" jsonb NOT NULL DEFAULT '[]'`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "timeout_in_seconds" integer NOT NULL`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "memory_limit_mb" integer NOT NULL DEFAULT '512'`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "cpu_cores" integer NOT NULL DEFAULT '1'`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "pid_limit" integer NOT NULL DEFAULT '100'`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "entry_cmd" character varying`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "autolab_compatible" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD CONSTRAINT "CHK_e72da9ff159109a7c3f9afda10" CHECK (timeout_in_seconds > 0)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP CONSTRAINT "CHK_e72da9ff159109a7c3f9afda10"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "autolab_compatible"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "entry_cmd"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "pid_limit"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "cpu_cores"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "memory_limit_mb"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "timeout_in_seconds"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "job_file_ids"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" DROP COLUMN "dockerfile_id"`); + await queryRunner.query(`ALTER TABLE "FilesAuth" DROP COLUMN "type"`); + await queryRunner.query(`ALTER TABLE "FilesAuth" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "FilesAuth" DROP COLUMN "etag"`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "grader_filename" character varying(128) NOT NULL`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "autograding_image" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "makefile_filename" text`); + await queryRunner.query(`ALTER TABLE "container_auto_grader" ADD "timeout" integer NOT NULL`); + } + +} diff --git a/devU-api/src/tango/tango.service.ts b/devU-api/src/tango/tango.service.ts deleted file mode 100644 index df26730f..00000000 --- a/devU-api/src/tango/tango.service.ts +++ /dev/null @@ -1,118 +0,0 @@ -import './tango.types' -import fetch from 'node-fetch' - -const tangoHost = `http://tango:3000` -const tangoKey = process.env.TANGO_KEY ?? 'test' - -// for more info https://docs.autolabproject.com/tango-rest/ - -/** - * Opens a directory for a given course. - * @param course - The course name. - */ -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()) as OpenResponse) : null -} - -/** - * 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(course: string, file: Buffer, fileName: string): Promise { - const url = `${tangoHost}/upload/${tangoKey}/${course}/` - const response = await fetch(url, { method: 'POST', body: file, headers: { filename: fileName } }) - return response.ok ? ((await response.json()) as UploadResponse) : null -} - -/** - * 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(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()) as AddJobResponse) : null -} - -/** - * Polls the server for the status of a job. - * @param course - The course name. - * @param outputFile - The name of the output file. - */ -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' }) - return response.ok -} - -/** - * Retrieves information about the Tango service. - */ -export async function getInfo(): Promise { - const url = `${tangoHost}/info/${tangoKey}/` - const response = await fetch(url, { method: 'GET' }) - return response.ok ? ((await response.json()) as InfoResponse) : null -} - -/** - * Retrieves information about the pool of instances for a given image. - * @param image - The name of the image. - */ -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()) as Object) : null -} - -/** - * Pre-allocates a pool of instances for a given image. - * @param image - The name of the image. - * @param num - The number of instances to pre-allocate. - * @param request - The request object. - */ -export async function preallocateInstances( - image: string, - num: number, - request: PreallocRequest -): Promise { - const url = `${tangoHost}/prealloc/${tangoKey}/${image}/${num}/` - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }) - return response.ok ? ((await response.json()) as PreallocResponse) : null -} - -/** - * Retrieves information about jobs. - * @param deadjobs - A flag to indicate whether to retrieve dead jobs (1) or running jobs (0). - * @returns Empty response on successful request - */ -export async function getJobs(deadjobs: number): Promise<{} | null> { - const url = `${tangoHost}/jobs/${tangoKey}/${deadjobs}/` - const response = await fetch(url, { method: 'POST' }) - return response.ok ? {} : null -} diff --git a/devU-api/src/tango/tango.types.ts b/devU-api/src/tango/tango.types.ts deleted file mode 100644 index 759dbd1a..00000000 --- a/devU-api/src/tango/tango.types.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Represents the response body for the `/open` request. - */ -interface OpenResponse { - statusMsg: string - statusId: number - files: { [fileName: string]: string } -} - -/** - * Represents the response body for the `/upload` request. - */ -interface UploadResponse { - statusMsg: string - statusId: number -} - -/** - * Represents a single file information object with `localFile` and `destFile` properties. - */ -interface FileInfo { - localFile: string - destFile: string -} - -/** - * Represents the request body for the `/addJob` request. - */ -interface AddJobRequest { - image: string - files: FileInfo[] - jobName: string - output_file: string - timeout?: number - max_kb?: number - callback_url?: string -} - -/** - * Represents the response body for the `/addJob` request. - */ -interface AddJobResponse { - statusMsg: string - statusId: number - jobId: number -} - -/** - * Represents the response body for the `/poll` request when the job is completed. - */ -interface PollSuccessResponse { - // The autograder output file content ??? -} - -/** - * Represents the response body for the `/poll` request when the job is not completed. - */ -interface PollFailureResponse { - statusMsg: string - statusId: number -} - -/** - * Represents the response body for the `/info` request. - */ -interface InfoResponse { - info: { - num_threads: number - job_requests: number - waitvm_timeouts: number - runjob_timeouts: number - elapsed_secs: number - runjob_errors: number - job_retries: number - copyin_errors: number - copyout_errors: number - } - statusMsg: string - statusId: number -} - -/** - * Represents the request body for the `/prealloc` request. - */ -interface PreallocRequest { - vmms: string - cores: number - memory: number -} - -/** - * Represents the response body for the `/prealloc` request. - */ -interface PreallocResponse { - status: string -} - -/** - * Represents the request body for the `/jobs` request. - */ -interface JobsRequest { - deadjobs: number -} diff --git a/devU-api/src/tango/tests/tango.endpoint._test.ts b/devU-api/src/tango/tests/tango.endpoint._test.ts deleted file mode 100644 index dae723b7..00000000 --- a/devU-api/src/tango/tests/tango.endpoint._test.ts +++ /dev/null @@ -1,14 +0,0 @@ -// TODO: What even is this file? It doesn't follow any of our testing structure - -// import { getInfo, preallocateInstances } from '../tango.service' -// import { getInfo } from '../tango.service' -// -// async function main() { -// const res = await getInfo() -// // const pre = preallocateInstances() -// console.log(res) -// } - -// main().then(value => { -// console.log('main complete') -// }) diff --git a/devU-client/package.json b/devU-client/package.json index ab842572..6e8ffa45 100644 --- a/devU-client/package.json +++ b/devU-client/package.json @@ -8,7 +8,7 @@ "build-development": "cross-env NODE_ENV=development webpack --mode=production", "build-all": "concurrently \"npm run build-development\" \"npm run build\"", "build-local": "cross-env NODE_ENV=local webpack --mode=production", - "build-docker": "SASS_DEPRECATE_IMPORT=no webpack --mode=production --no-stats", + "build-docker": "SASS_DEPRECATE_IMPORT=no webpack --mode=production", "format": "prettier --write \"src/**/*.{ts,tsx,json,md}\"", "pre-commit": "lint-staged", "dev-backend": "docker compose -f ../docker-compose.yml --profile dev-client up -d", diff --git a/devU-client/src/assets/global.scss b/devU-client/src/assets/global.scss index bb74e04d..5a5bf92b 100644 --- a/devU-client/src/assets/global.scss +++ b/devU-client/src/assets/global.scss @@ -23,10 +23,15 @@ text-align: center; } - a{ + hr{ + border: 1px solid #ddd; + margin: 0; + } + + a { color: var(--link-blue); } - + // general button template, extends to 3 types of buttons - primary, secondary, delete .btn { cursor: pointer; @@ -36,20 +41,25 @@ font-weight: 700; font-size: 16px; transition: all 0.2s ease; + &:disabled{ + opacity: 60%; + cursor: not-allowed; + } } .no_items { font-style: italic; margin-right: auto; // Keeps text locked left after hitting 738px margin-bottom: 20px; -} + } button.btnPrimary { @extend .btn; background-color: var(--primary); border: 3px solid var(--primary); color: #fff; // primary button always white text - &:hover{ + + &:hover { background-color: var(--hover-darker); border: 3px solid var(--hover-darker); } @@ -57,11 +67,12 @@ button.btnSecondary { @extend .btn; - + background-color: var(--btn-secondary-background); color: var(--btn-secondary-text); border: 3px solid var(--btn-secondary-border); - &:hover{ + + &:hover { background-color: var(--hover-lighter); } } @@ -90,21 +101,25 @@ width: 100% } - .input-group>input, .input-group>textarea{ + .input-group>input, + .input-group>textarea { padding: 10px; border-radius: 10px; // border: 1px solid var(--input-field-label); border: none; background-color: var(--input-field-background); color: var(--text-color); - font-family: 'Source Sans Pro', 'Helvetica', 'Arial', sans-serif;; + font-family: 'Source Sans Pro', 'Helvetica', 'Arial', sans-serif; + ; } - .input-group>input::placeholder, .input-group>textarea::placeholder { + .input-group>input::placeholder, + .input-group>textarea::placeholder { color: var(--input-field-label); } - .modal-header, .input-subgroup-2col { + .modal-header, + .input-subgroup-2col { display: flex; justify-content: space-between; gap: 20px; @@ -127,6 +142,21 @@ align-self: center; } + .pageHeader { + display: grid; + grid-template-columns: 1fr 2fr 1fr; + align-items: center; + } + + .pageHeader > h1 { + grid-column-start: 2; + } + + .pageHeaderBtn { + @extend .btnPrimary; + margin-left: auto; + } + body { font-family: 'Source Sans Pro', 'Helvetica', 'Arial', sans-serif; margin: 0; @@ -135,7 +165,7 @@ background-color: var(--background); color: var(--text-color); - + --background: white; --text-color: black; --text-color-secondary: #363636; @@ -176,7 +206,7 @@ --input-field-background: #fff; --input-field-label: var(--grey); - --table-row-even: var(--grey-lighter); + --table-row-even: var(--background); --table-row-odd: var(--grey-lightest); --red-text: var(--red); @@ -187,7 +217,7 @@ --grey-lighter: #c5c5c5; --grey: #555555; --grey-darker: #444444; - --grey-darkest: #333333; + --grey-darkest: #333333; --blue-lighter: #78B7FF; --blue: #1F3D7A; @@ -222,6 +252,8 @@ --secondary: #3d3f3f; --secondary-darker: #1f2020; + --link-blue: #0b8cdb; + --list-item-background: #333333; --list-item-background-hover: #303030; --list-item-subtext: #e0e0e0; @@ -239,9 +271,7 @@ --hover-darker: var(--purple-lighterer); --hover-lighter: var(--purple); - --btn-text-color: var(--purple-lighter) - - --btn-delete-border: var(--red-lighter); + --btn-text-color: var(--purple-lighter) --btn-delete-border: var(--red-lighter); --btn-delete-background: var(--red); --btn-delete-text: #FFF; @@ -258,13 +288,14 @@ color: var(--text-color); } - // General Error Message + // General Error Message .error-message { color: var(--error-text); font-size: 0.875rem; font-weight: bold; margin-top: 5px; } + // Validation Error Container .error-container { background-color: var(--error-background); @@ -273,6 +304,7 @@ margin: 10px 0; border-radius: 5px; } + // Error Page Styling (From `errorPage.scss`) .error-page { background-color: var(--error-page-background); @@ -281,14 +313,17 @@ justify-content: center; height: 100vh; } + .error-heading { font-size: 2rem; color: var(--error-text); } + .error-description { font-size: 1.2rem; color: var(--text-color); } + // Global Input Field Styles .input-field { width: 100%; @@ -304,6 +339,7 @@ border-color: var(--focus); outline: none; } + &:disabled { background-color: var(--grey-lightest); color: var(--grey-lighter); @@ -311,8 +347,8 @@ } } - - + + // Placeholder Styling ::placeholder { color: var(--text-color-secondary); @@ -322,4 +358,4 @@ *:focus { outline-color: var(--focus); } -} +} \ No newline at end of file diff --git a/devU-client/src/assets/variables.scss b/devU-client/src/assets/variables.scss index cdc4cb9b..a5b55e7d 100644 --- a/devU-client/src/assets/variables.scss +++ b/devU-client/src/assets/variables.scss @@ -39,6 +39,7 @@ $grey: var(--grey); $blue-lighter: var(--blue-lighter); $blue: var(--blue); +$link-blue: var(--link-blue); $red-lighter: var(--red-lighter); $red: var(--red); @@ -87,7 +88,7 @@ $small: 600px; $extreme: 780px; $pagePadding: 100px; -$phonePadding: 50px; +$phonePadding: 25px; @mixin ellipsis { overflow: hidden; diff --git a/devU-client/src/components/authenticatedRouter.tsx b/devU-client/src/components/authenticatedRouter.tsx index f66ba37b..94a01982 100644 --- a/devU-client/src/components/authenticatedRouter.tsx +++ b/devU-client/src/components/authenticatedRouter.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Route, Switch} from 'react-router-dom' +import { Route, Switch } from 'react-router-dom' import AssignmentDetailPage from 'components/pages/assignments/assignmentDetailPage' import AssignmentUpdatePage from 'components/pages/forms/assignments/assignmentUpdatePage' @@ -14,7 +14,7 @@ import NonContainerAutoGraderForm from './pages/forms/containers/nonContainerAut import GradebookStudentPage from './pages/gradebook/gradebookStudentPage' import GradebookInstructorPage from './pages/gradebook/gradebookInstructorPage' import SubmissionFeedbackPage from './pages/submissions/submissionFeedbackPage' -import ContainerAutoGraderForm from './pages/forms/containers/containerAutoGraderForm' +import ContainerAutoGraderForm from './pages/forms/containers/containerAutoGraderModal' import CoursePreviewPage from './pages/courses/coursePreviewPage' import CoursesListPage from "./pages/listPages/courses/coursesListPage"; import AssignmentProblemFormPage from './pages/forms/assignments/assignmentProblemFormPage' @@ -22,46 +22,53 @@ import InstructorSubmissionspage from "./pages/submissions/InstructorSubmissions import SubmissionFileView from './pages/submissions/submissionFileView' import UserCoursesListPage from "./pages/listPages/courses/coursesListPage"; import JoinCoursePage from "./pages/listPages/joinwithcodepage"; - +import InstructorAttendancePage from './pages/Attendence/InstructorAttendancePage'; +import matchingTable from './pages/Multiplechoice/matchingTable'; import WebhookURLForm from './pages/webhookURLForm' -// import AddAssignmentModal from 'components/pages/forms/assignments/assignmentFormPage' const AuthenticatedRouter = () => ( - - - + + + - + - - - - - + + + + + - - + + - - - + component={NonContainerAutoGraderForm} /> + + + + + + + - + component={SubmissionDetailPage} /> + + + {/* NOTE: maybe get rid of separate feedback page, instead have page to view source (/submissions/:submissionID/view on autolab)*/} - - - - // TBD, undecided where webhooks should be placed + component={SubmissionFeedbackPage} /> + + + + + {/* // TBD, undecided where webhooks should be placed */} {/**/} - + ) diff --git a/devU-client/src/components/listItems/assignmentProblemListItem.scss b/devU-client/src/components/listItems/assignmentProblemListItem.scss new file mode 100644 index 00000000..ce2c1c1c --- /dev/null +++ b/devU-client/src/components/listItems/assignmentProblemListItem.scss @@ -0,0 +1,117 @@ +@import 'variables'; + + +.problem_header{ + font-size:16px; + margin: 0 0 10px 0; +} + +.problem{ + gap: 10px; + padding: 10px 0; +} + + +.textField{ + font-family: $font-family; + font-size: 16px; + background: none; + border: 2px solid #ccc; + color: $text-color; + border-radius: 10px; + padding: 10px; + box-sizing: border-box; + width: 100%; +} + +.mcqLabel{ + display: block; + position: relative; + padding-left: 25px; + margin-bottom: 5px; + cursor: pointer; + width: fit-content; + + input { + position: absolute; + opacity: 0; + height: 0; + width: 0; + } + + .radio { + position: absolute; + transition: all .1s ease; + top: 3px; + left: 0; + height: 18px; + width: 18px; + background-color: $background; + border: 1px solid #999; + border-radius: 100px; + margin-left: 0; + } + + + + .radio::after { + width: 12px; + height: 12px; + border-radius: 100%; + content: ""; + position: absolute; + display: none; + } + + .checkbox { + position: absolute; + transition: all .2s ease; + top: 3px; + left: 0; + height: 18px; + width: 18px; + background-color: $background; + border: 1px solid #999; + border-radius: 5px; + margin-left: 0; + .checkboxCheck { + opacity: 0; + width: 15px; + left: 2px; + top: 2px; + height: 15px; + border-radius: 5px; + color: #fff; + position: absolute; + } + } + + + + + input:checked { + ~ .radio { + background-color: $primary; + border: 1px solid $primary; + } + + ~ .checkbox{ + background-color: $primary; + border: 1px solid $primary; + .checkboxCheck{ + opacity: 100%; + } + } + + + + ~ .radio::after { + display: block; + border: 3px solid #fff; + } + } + + &:last-of-type{ + margin-bottom: 0; + } +} \ No newline at end of file diff --git a/devU-client/src/components/listItems/assignmentProblemListItem.tsx b/devU-client/src/components/listItems/assignmentProblemListItem.tsx new file mode 100644 index 00000000..42f06129 --- /dev/null +++ b/devU-client/src/components/listItems/assignmentProblemListItem.tsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect} from 'react' +import { useParams } from 'react-router-dom' +import RequestService from 'services/request.service' +import {AssignmentProblem, NonContainerAutoGrader} from 'devu-shared-modules' + +import styles from './assignmentProblemListItem.scss' +import FaIcon from 'components/shared/icons/faIcon' + +type Props = { + problem: AssignmentProblem + handleChange?: (e : React.ChangeEvent) => void + disabled?: boolean +} + +const AssignmentProblemListItem = ({problem, handleChange, disabled}: Props) => { + const { courseId } = useParams<{ courseId: string }>() + const [ncags, setNcags] = useState([]) + + //const type = ncags.at(0)?.metadata + + const fetchNcags = async() => { + await RequestService.get(`/api/course/${courseId}/assignment/${problem.assignmentId}/non-container-auto-graders`).then((res) => setNcags(res)) + } + + const getMeta = () => { + if (ncags && ncags.length > 0){ + const ncag = ncags.find(ncag => ((ncag.question == problem.problemName) && (ncag.createdAt === problem.createdAt))) // currently checking against createdAt since if two non-code questions have the same name they can be confused otherwise, can be removed once meta string added to assignemntproblem + if (!ncag || !ncag.metadata) { + return undefined + } + return JSON.parse(ncag.metadata) + } + } + + + useEffect(() => { + fetchNcags() + }, []) + + const meta = getMeta() + if (!meta || !meta.type){ + return ( +
+
File Input Problems are not done yet pending backend changes! :D
+
) + } + + const type = meta.type + if (type == "Text") { + return ( +
+

{problem.problemName}

+ +
+ )} else if(type == "MCQ-mult") { + const options = meta.options + if (!options){ + return
+ } + return ( +
+

{problem.problemName}

+ {Object.keys(options).map((key : string) => ( + ))} +
) + } else if(type == "MCQ-single") { + const options = meta.options + if (!options){ + return
+ } + return ( +
+

{problem.problemName}

+ {Object.keys(options).map((key : string) => ( + ))} +
) + } else { + return( +
Unknown type, something is wrong on the backend!
) + } +} + + +export default AssignmentProblemListItem \ No newline at end of file diff --git a/devU-client/src/components/listItems/simpleAssignmentListItem.scss b/devU-client/src/components/listItems/simpleAssignmentListItem.scss index 7716d36b..345ccbf2 100644 --- a/devU-client/src/components/listItems/simpleAssignmentListItem.scss +++ b/devU-client/src/components/listItems/simpleAssignmentListItem.scss @@ -49,14 +49,13 @@ flex-direction: column; - border-radius: 0.6rem; - transition: background-color 0.2s linear; + transition: all 0.2s ease; // color: $primary; color: $text-color; - // &:hover, - // &:focus { - // background: $list-simple-item-background-hover; - // } + &:hover, + &:focus { + background-color: $list-item-background-hover; + } } \ No newline at end of file diff --git a/devU-client/src/components/listItems/simpleAssignmentListItem.tsx b/devU-client/src/components/listItems/simpleAssignmentListItem.tsx index a1dd63ef..8af9e05b 100644 --- a/devU-client/src/components/listItems/simpleAssignmentListItem.tsx +++ b/devU-client/src/components/listItems/simpleAssignmentListItem.tsx @@ -10,16 +10,17 @@ type Props = { } const SimpleAssignmentListItem = ({assignment}: Props) => ( +
(e.stopPropagation())}> {/*Wrapped in div so that clicking this item does not propogate to course cards onClick and take you to course detail page */}
{assignment.name}
- Due: {wordPrintDate(assignment.dueDate)} |   + Due: {wordPrintDate(assignment.dueDate)} +  |  End: {wordPrintDate(assignment.endDate)}
- -
+
) diff --git a/devU-client/src/components/listItems/userCourseListItem.scss b/devU-client/src/components/listItems/userCourseListItem.scss index 6e709717..9782d2c7 100644 --- a/devU-client/src/components/listItems/userCourseListItem.scss +++ b/devU-client/src/components/listItems/userCourseListItem.scss @@ -6,7 +6,6 @@ font-weight: 600; margin: 0; padding: 15px; - /* Add padding to the text inside the name block */ background: $primary; width: 100%; text-align: center; @@ -33,12 +32,12 @@ text-overflow: ellipsis; overflow-wrap: break-word; border-bottom: 1px solid #ddd; - } .Buttons { display: flex; justify-content: center; + cursor: default; margin-top: auto; padding: 15px; gap: 20px; diff --git a/devU-client/src/components/misc/editAssignmentModal.tsx b/devU-client/src/components/misc/editAssignmentModal.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/devU-client/src/components/misc/footer.scss b/devU-client/src/components/misc/footer.scss index 5a2abeb0..4b288854 100644 --- a/devU-client/src/components/misc/footer.scss +++ b/devU-client/src/components/misc/footer.scss @@ -4,13 +4,10 @@ background-color: $primary; color: #fff; text-align: left; - width: 100%; - position: fixed; - bottom: 0; - left: 0; + margin-top: auto; font-size: 14px; font-size: 16px; - padding: 10px 0; + padding: 10px $pagePadding; display: flex; flex-direction: row; align-items: center; @@ -34,3 +31,8 @@ } } } +@media (max-width: $extreme){ + .footer{ + padding: 10px $phonePadding; + } +} diff --git a/devU-client/src/components/misc/globalToolbar.scss b/devU-client/src/components/misc/globalToolbar.scss index bee326f1..eee6a4f2 100644 --- a/devU-client/src/components/misc/globalToolbar.scss +++ b/devU-client/src/components/misc/globalToolbar.scss @@ -34,7 +34,7 @@ $font-size: 16px; } .bar { - height: $bar-height; + min-height: $bar-height; background-color: $primary; font-size: 40px; color: #D9D9D9; diff --git a/devU-client/src/components/pages/Attendence/InstructorAttendanceModal.tsx b/devU-client/src/components/pages/Attendence/InstructorAttendanceModal.tsx new file mode 100644 index 00000000..08a1effd --- /dev/null +++ b/devU-client/src/components/pages/Attendence/InstructorAttendanceModal.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import Modal from 'components/shared/layouts/modal'; +import './attendancePage.scss'; + +interface Props { + open: boolean; + onClose: () => void; +} + +const InstructorAttendanceModal: React.FC = ({ open, onClose }) => { + const [course, setCourse] = useState(''); + const [date, setDate] = useState(''); + const [code, setCode] = useState(''); + const [duration, setDuration] = useState('15'); + const [description, setDescription] = useState(''); + + const handleGenerateCode = () => { + const randomCode = Math.random().toString(36).substring(2, 7).toUpperCase(); + setCode(randomCode); + }; + + const handleSubmit = () => { + const attendanceData = { course, date, code, duration, description }; + console.log('Submitting attendance:', attendanceData); + onClose(); + }; + + return ( + +
+
+ + +
+ +
+ + setDate(e.target.value)} + required + /> +
+ +
+ +
+ setCode(e.target.value.toUpperCase())} + placeholder="Enter or generate a code" + required + /> + +
+
+ +
+ + setDuration(e.target.value)} + required + /> +
+ +
+ +