diff --git a/ExpressAPI/assets/OpenAPI/OpenAPI.yaml b/ExpressAPI/assets/OpenAPI/OpenAPI.yaml index 2bbe1d3a26375c79d0b02d84bb0773b74d13712c..b4d9846f36a75a1b46f1a023543eb92c7137588f 100644 --- a/ExpressAPI/assets/OpenAPI/OpenAPI.yaml +++ b/ExpressAPI/assets/OpenAPI/OpenAPI.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Dojo API - version: 3.4.2 + version: 3.5.0 description: | **Backend API of the Dojo project.** diff --git a/ExpressAPI/prisma/migrations/20240208132018_add_correction_to_assignment/migration.sql b/ExpressAPI/prisma/migrations/20240208132018_add_correction_to_assignment/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..cf8fa0240acfd338b0414f0394685a96b8b2602b --- /dev/null +++ b/ExpressAPI/prisma/migrations/20240208132018_add_correction_to_assignment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Exercise` ADD COLUMN `correctionCommit` JSON NULL; diff --git a/ExpressAPI/prisma/schema.prisma b/ExpressAPI/prisma/schema.prisma index a68226e0868e475d54557f330fa35699e2f34bf7..f60367392fbfdcdf104a78dea1e20a0101bdddb9 100644 --- a/ExpressAPI/prisma/schema.prisma +++ b/ExpressAPI/prisma/schema.prisma @@ -50,6 +50,8 @@ model Exercise { gitlabLastInfo Json @db.Json gitlabLastInfoDate DateTime + correctionCommit Json? @db.Json + assignment Assignment @relation(fields: [assignmentName], references: [name], onDelete: NoAction, onUpdate: Cascade) members User[] diff --git a/ExpressAPI/src/helpers/DatabaseHelper.ts b/ExpressAPI/src/helpers/DatabaseHelper.ts index 89ec9c767cb8f879011a7f5e547eb1942657de7b..9dc14bb292ef7e2921617c135b1e4f8d014e2a78 100644 --- a/ExpressAPI/src/helpers/DatabaseHelper.ts +++ b/ExpressAPI/src/helpers/DatabaseHelper.ts @@ -1,7 +1,9 @@ -import { PrismaClient } from '@prisma/client'; -import logger from '../shared/logging/WinstonLogger'; -import UserQueryExtension from './Prisma/Extensions/UserQueryExtension'; -import UserResultExtension from './Prisma/Extensions/UserResultExtension'; +import { PrismaClient } from '@prisma/client'; +import logger from '../shared/logging/WinstonLogger'; +import UserQueryExtension from './Prisma/Extensions/UserQueryExtension'; +import UserResultExtension from './Prisma/Extensions/UserResultExtension'; +import AssignmentResultExtension from './Prisma/Extensions/AssignmentResultExtension'; +import ExerciseResultExtension from './Prisma/Extensions/ExerciseResultExtension'; const prisma = new PrismaClient({ @@ -29,7 +31,7 @@ prisma.$on('warn', e => logger.warn(`Prisma => ${ e.message }`)); prisma.$on('error', e => logger.error(`Prisma => ${ e.message }`)); -const db = prisma.$extends(UserQueryExtension).$extends(UserResultExtension); +const db = prisma.$extends(UserQueryExtension).$extends(UserResultExtension).$extends(AssignmentResultExtension).$extends(ExerciseResultExtension); export default db; \ No newline at end of file diff --git a/ExpressAPI/src/helpers/DojoValidators.ts b/ExpressAPI/src/helpers/DojoValidators.ts index 98a28252c6bca786fe23ad6b08e56db54c33aca0..38465417c46274cff0e46ee3dbdc2929cb17f05b 100644 --- a/ExpressAPI/src/helpers/DojoValidators.ts +++ b/ExpressAPI/src/helpers/DojoValidators.ts @@ -7,6 +7,8 @@ import express from 'expres import logger from '../shared/logging/WinstonLogger'; import Json5FileValidator from '../shared/helpers/Json5FileValidator'; import ExerciseResultsFile from '../shared/types/Dojo/ExerciseResultsFile'; +import ParamsCallbackManager from '../middlewares/ParamsCallbackManager'; +import ExerciseManager from '../managers/ExerciseManager'; declare type DojoMeta = Meta & { @@ -106,6 +108,32 @@ class DojoValidators { }); } }); + + readonly exerciseIdOrUrlValidator = this.toValidatorSchemaOptions({ + bail : true, + errorMessage: 'ExerciseIdOrUrl: not provided or invalid', + options : (_value, { + req, + path + }) => { + return new Promise((resolve, reject) => { + const exerciseIdOrUrl = this.getParamValue(req, path) as string; + if ( exerciseIdOrUrl ) { + ParamsCallbackManager.initBoundParams(req); + + ExerciseManager.get(exerciseIdOrUrl).then((exercise) => { + req.boundParams.exercise = exercise; + + exercise !== undefined ? resolve(true) : reject(); + }).catch(() => { + reject(); + }); + } else { + reject(); + } + }); + } + }); } diff --git a/ExpressAPI/src/helpers/Prisma/Extensions/AssignmentResultExtension.ts b/ExpressAPI/src/helpers/Prisma/Extensions/AssignmentResultExtension.ts new file mode 100644 index 0000000000000000000000000000000000000000..56819c91d853807036d4848fd6b9fbefcc128792 --- /dev/null +++ b/ExpressAPI/src/helpers/Prisma/Extensions/AssignmentResultExtension.ts @@ -0,0 +1,36 @@ +import { Prisma } from '@prisma/client'; +import { Exercise } from '../../../types/DatabaseTypes'; +import db from '../../DatabaseHelper'; +import LazyVal from '../../../shared/helpers/LazyVal'; + + +async function getCorrections(assignment: { name: string }): Promise<Array<Exercise> | undefined> { + try { + return await db.exercise.findMany({ + where: { + assignmentName : assignment.name, + correctionCommit: { + not: Prisma.JsonNull + } + } + }) as Array<Exercise> ?? undefined; + } catch ( e ) { + return undefined; + } +} + +export default Prisma.defineExtension(client => { + return client.$extends({ + result: { + assignment: { + corrections: { + compute(assignment) { + return new LazyVal<Array<Exercise> | undefined>(() => { + return getCorrections(assignment); + }); + } + } + } + } + }); +}); \ No newline at end of file diff --git a/ExpressAPI/src/helpers/Prisma/Extensions/ExerciseResultExtension.ts b/ExpressAPI/src/helpers/Prisma/Extensions/ExerciseResultExtension.ts new file mode 100644 index 0000000000000000000000000000000000000000..e11b4e247e78b59b49e0ebd52cb669857954d431 --- /dev/null +++ b/ExpressAPI/src/helpers/Prisma/Extensions/ExerciseResultExtension.ts @@ -0,0 +1,19 @@ +import { Prisma } from '@prisma/client'; + + +export default Prisma.defineExtension(client => { + return client.$extends({ + result: { + exercise: { + isCorrection: { + needs: { + correctionCommit: true + }, + compute(exercise) { + return exercise.correctionCommit != null; + } + } + } + } + }); +}); \ No newline at end of file diff --git a/ExpressAPI/src/managers/ExerciseManager.ts b/ExpressAPI/src/managers/ExerciseManager.ts index 66cfda8a89bb8f8d726f03c54a2bdcf915b45914..720827d904b1332b90b632aa2ca965b23586b45f 100644 --- a/ExpressAPI/src/managers/ExerciseManager.ts +++ b/ExpressAPI/src/managers/ExerciseManager.ts @@ -4,7 +4,9 @@ import db from '../helpers/DatabaseHelper'; class ExerciseManager { - async get(id: string, include: Prisma.ExerciseInclude | undefined = undefined): Promise<Exercise | undefined> { + async get(idOrUrl: string, include: Prisma.ExerciseInclude | undefined = undefined): Promise<Exercise | undefined> { + const id = idOrUrl.replace('.git', '').split('_').pop()!; + return await db.exercise.findUnique({ where : { id: id diff --git a/ExpressAPI/src/managers/GitlabManager.ts b/ExpressAPI/src/managers/GitlabManager.ts index 390fba57640e11b4514caa37df8131a4b9622886..3455f8b7accad04dc7c09ace93b1c297cb633d49 100644 --- a/ExpressAPI/src/managers/GitlabManager.ts +++ b/ExpressAPI/src/managers/GitlabManager.ts @@ -1,22 +1,29 @@ -import axios from 'axios'; -import Config from '../config/Config'; -import GitlabRepository from '../shared/types/Gitlab/GitlabRepository'; -import GitlabAccessLevel from '../shared/types/Gitlab/GitlabAccessLevel'; -import GitlabMember from '../shared/types/Gitlab/GitlabMember'; -import { StatusCodes } from 'http-status-codes'; -import GitlabVisibility from '../shared/types/Gitlab/GitlabVisibility'; -import GitlabUser from '../shared/types/Gitlab/GitlabUser'; -import GitlabTreeFile from '../shared/types/Gitlab/GitlabTreeFile'; -import parseLinkHeader from 'parse-link-header'; -import GitlabFile from '../shared/types/Gitlab/GitlabFile'; -import express from 'express'; -import GitlabRoute from '../shared/types/Gitlab/GitlabRoute'; -import SharedConfig from '../shared/config/SharedConfig'; -import GitlabProfile from '../shared/types/Gitlab/GitlabProfile'; -import GitlabRelease from '../shared/types/Gitlab/GitlabRelease'; +import axios from 'axios'; +import Config from '../config/Config'; +import GitlabRepository from '../shared/types/Gitlab/GitlabRepository'; +import GitlabAccessLevel from '../shared/types/Gitlab/GitlabAccessLevel'; +import GitlabMember from '../shared/types/Gitlab/GitlabMember'; +import { StatusCodes } from 'http-status-codes'; +import GitlabVisibility from '../shared/types/Gitlab/GitlabVisibility'; +import GitlabUser from '../shared/types/Gitlab/GitlabUser'; +import GitlabTreeFile from '../shared/types/Gitlab/GitlabTreeFile'; +import parseLinkHeader from 'parse-link-header'; +import GitlabFile from '../shared/types/Gitlab/GitlabFile'; +import express from 'express'; +import GitlabRoute from '../shared/types/Gitlab/GitlabRoute'; +import SharedConfig from '../shared/config/SharedConfig'; +import GitlabProfile from '../shared/types/Gitlab/GitlabProfile'; +import GitlabRelease from '../shared/types/Gitlab/GitlabRelease'; +import { CommitSchema, Gitlab } from '@gitbeaker/rest'; +import logger from '../shared/logging/WinstonLogger'; class GitlabManager { + readonly api = new Gitlab({ + host : SharedConfig.gitlab.URL, + token: Config.gitlab.account.token + }); + private getApiUrl(route: GitlabRoute): string { return `${ SharedConfig.gitlab.apiURL }${ route }`; } @@ -75,6 +82,21 @@ class GitlabManager { return response.data; } + async getRepositoryLastCommit(repoId: number, branch: string = 'main'): Promise<CommitSchema | undefined> { + try { + const commits = await this.api.Commits.all(repoId, { + refName : branch, + maxPages: 1, + perPage : 1 + }); + + return commits.length > 0 ? commits[0] : undefined; + } catch ( e ) { + logger.error(e); + return undefined; + } + } + async createRepository(name: string, description: string, visibility: string, initializeWithReadme: boolean, namespace: number, sharedRunnersEnabled: boolean, wikiEnabled: boolean, import_url: string): Promise<GitlabRepository> { const response = await axios.post<GitlabRepository>(this.getApiUrl(GitlabRoute.REPOSITORY_CREATE), { name : name, diff --git a/ExpressAPI/src/middlewares/ParamsCallbackManager.ts b/ExpressAPI/src/middlewares/ParamsCallbackManager.ts index cfcb275a4c3239e33f83fbb6360a10e31b5b0141..6dd5303a36e38091833b733472c0227733788ebe 100644 --- a/ExpressAPI/src/middlewares/ParamsCallbackManager.ts +++ b/ExpressAPI/src/middlewares/ParamsCallbackManager.ts @@ -39,7 +39,7 @@ class ParamsCallbackManager { staff : true } ], 'assignment'); - this.listenParam('exerciseId', backend, (ExerciseManager.get as GetFunction).bind(ExerciseManager), [ { + this.listenParam('exerciseIdOrUrl', backend, (ExerciseManager.get as GetFunction).bind(ExerciseManager), [ { assignment: true, members : true, results : true diff --git a/ExpressAPI/src/routes/AssignmentRoutes.ts b/ExpressAPI/src/routes/AssignmentRoutes.ts index ab2bd23456d446fd6a23a64e2bd9db7383b9b05c..c32ee2dfa6a061a997ca10a0067c592e9756814f 100644 --- a/ExpressAPI/src/routes/AssignmentRoutes.ts +++ b/ExpressAPI/src/routes/AssignmentRoutes.ts @@ -45,12 +45,23 @@ class AssignmentRoutes implements RoutesManager { } }; + private readonly assignmentAddCorrigeValidator: ExpressValidator.Schema = { + exerciseIdOrUrl: { + trim : true, + notEmpty: true, + custom : DojoValidators.exerciseIdOrUrlValidator + } + }; + registerOnBackend(backend: Express) { backend.get('/assignments/:assignmentNameOrUrl', SecurityMiddleware.check(true), this.getAssignment.bind(this)); backend.post('/assignments', SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF), ParamsValidatorMiddleware.validate(this.assignmentValidator), this.createAssignment.bind(this)); - backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.publishAssignment.bind(this)); - backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.unpublishAssignment.bind(this)); + backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.changeAssignmentPublishedStatus(true).bind(this)); + backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.changeAssignmentPublishedStatus(false).bind(this)); + + backend.post('/assignments/:assignmentNameOrUrl/corrections', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), ParamsValidatorMiddleware.validate(this.assignmentAddCorrigeValidator), this.addUpdateAssignmentCorrection(false).bind(this)); + backend.patch('/assignments/:assignmentNameOrUrl/corrections/:exerciseIdOrUrl', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.addUpdateAssignmentCorrection(true).bind(this)); } // Get an assignment by its name or gitlab url @@ -171,14 +182,6 @@ class AssignmentRoutes implements RoutesManager { } } - private async publishAssignment(req: express.Request, res: express.Response) { - return this.changeAssignmentPublishedStatus(true)(req, res); - } - - private async unpublishAssignment(req: express.Request, res: express.Response) { - return this.changeAssignmentPublishedStatus(false)(req, res); - } - private changeAssignmentPublishedStatus(publish: boolean): (req: express.Request, res: express.Response) => Promise<void> { return async (req: express.Request, res: express.Response): Promise<void> => { if ( publish ) { @@ -213,6 +216,39 @@ class AssignmentRoutes implements RoutesManager { }; } + private addUpdateAssignmentCorrection(isUpdate: boolean): (req: express.Request, res: express.Response) => Promise<void> { + return async (req: express.Request, res: express.Response): Promise<void> => { + if ( req.boundParams.exercise?.assignmentName != req.boundParams.assignment?.name ) { + return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'The exercise does not belong to the assignment', DojoStatusCode.ASSIGNMENT_EXERCISE_NOT_RELATED); + } + + if ( !req.boundParams.assignment?.published ) { + return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'The assignment must be public', DojoStatusCode.ASSIGNMENT_NOT_PUBLISHED); + } + + if ( !isUpdate && req.boundParams.exercise?.isCorrection ) { + return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'This exercise is already a correction', DojoStatusCode.EXERCISE_CORRECTION_ALREADY_EXIST); + } else if ( isUpdate && !req.boundParams.exercise?.isCorrection ) { + return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'This exercise is not a correction', DojoStatusCode.EXERCISE_CORRECTION_NOT_EXIST); + } + + const lastCommit = await GitlabManager.getRepositoryLastCommit(req.boundParams.assignment!.gitlabId); + if ( lastCommit ) { + await db.exercise.update({ + where: { + id: req.boundParams.exercise!.id + }, + data : { + correctionCommit: lastCommit + } + }); + + return req.session.sendResponse(res, StatusCodes.OK); + } else { + return req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, undefined, 'No last commit found'); + } + }; + } } diff --git a/ExpressAPI/src/routes/ExerciseRoutes.ts b/ExpressAPI/src/routes/ExerciseRoutes.ts index 0642d3a99433e7d75432d573e8fda1f515f0b936..1317f5b40469d183c88323ee17cf2e5beab4176e 100644 --- a/ExpressAPI/src/routes/ExerciseRoutes.ts +++ b/ExpressAPI/src/routes/ExerciseRoutes.ts @@ -72,9 +72,9 @@ class ExerciseRoutes implements RoutesManager { registerOnBackend(backend: Express) { backend.post('/assignments/:assignmentNameOrUrl/exercises', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_IS_PUBLISHED), ParamsValidatorMiddleware.validate(this.exerciseValidator), this.createExercise.bind(this)); - backend.get('/exercises/:exerciseId/assignment', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), this.getAssignment.bind(this)); + backend.get('/exercises/:exerciseIdOrUrl/assignment', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), this.getAssignment.bind(this)); - backend.post('/exercises/:exerciseId/results', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), ParamsValidatorMiddleware.validate(this.resultValidator), this.createResult.bind(this)); + backend.post('/exercises/:exerciseIdOrUrl/results', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), ParamsValidatorMiddleware.validate(this.resultValidator), this.createResult.bind(this)); } private getExerciseName(assignment: Assignment, members: Array<GitlabUser>, suffix: number): string { diff --git a/ExpressAPI/src/shared b/ExpressAPI/src/shared index 75f67b647da34337f3b220cacf78b2115d6022bc..9e3f29d2f313ef96944a199da0db39f1827c496a 160000 --- a/ExpressAPI/src/shared +++ b/ExpressAPI/src/shared @@ -1 +1 @@ -Subproject commit 75f67b647da34337f3b220cacf78b2115d6022bc +Subproject commit 9e3f29d2f313ef96944a199da0db39f1827c496a diff --git a/ExpressAPI/src/types/DatabaseTypes.ts b/ExpressAPI/src/types/DatabaseTypes.ts index 632b05be5eda9eebf30a8c392e5a4020292dd10d..ae95e3bfb03a2b7c52dab17b486f36439ae139f6 100644 --- a/ExpressAPI/src/types/DatabaseTypes.ts +++ b/ExpressAPI/src/types/DatabaseTypes.ts @@ -31,6 +31,10 @@ export type User = Prisma.UserGetPayload<typeof userBase> & { isAdmin: boolean gitlabProfile: LazyVal<GitlabUser> } -export type Assignment = Prisma.AssignmentGetPayload<typeof assignmentBase> -export type Exercise = Prisma.ExerciseGetPayload<typeof exerciseBase> +export type Exercise = Prisma.ExerciseGetPayload<typeof exerciseBase> & { + isCorrection: boolean +} +export type Assignment = Prisma.AssignmentGetPayload<typeof assignmentBase> & { + corrections: LazyVal<Exercise> +} export type Result = Prisma.ResultGetPayload<typeof resultBase> \ No newline at end of file