Skip to content
Snippets Groups Projects
Commit b4ff63ce authored by michael.minelli's avatar michael.minelli
Browse files

Merge branch 'v3.5.0'

parents 7bc37c79 acd19aaf
No related branches found
No related tags found
No related merge requests found
Pipeline #29617 passed
Showing
with 695 additions and 439 deletions
......@@ -18,7 +18,13 @@
-->
## 3.4.2 (???)
## 3.5.0 (???)
### ✨ Feature
- Link a commit of an exercise as a corrige of an assignment
## 3.4.2 (2023-01-23)
### 📚 Documentation
- Wiki: add tutorial about adding a new route to the API
......
openapi: 3.1.0
info:
title: Dojo API
version: 3.4.2
version: 3.5.0
description: |
**Backend API of the Dojo project.**
......
This diff is collapsed.
{
"name" : "dojo_backend_api",
"description" : "Backend API of the Dojo project",
"version" : "3.4.2",
"version" : "3.5.0",
"license" : "AGPLv3",
"author" : "Michaël Minelli <dojo@minelli.me>",
"main" : "dist/src/app.js",
......@@ -28,11 +28,12 @@
"seed": "node dist/prisma/seed"
},
"dependencies" : {
"@prisma/client" : "^5.8.1",
"axios" : "^1.6.5",
"@gitbeaker/rest" : "^39.34.2",
"@prisma/client" : "^5.9.1",
"axios" : "^1.6.7",
"compression" : "^1.7.4",
"cors" : "^2.8.5",
"dotenv" : "^16.3.1",
"dotenv" : "^16.4.1",
"dotenv-expand" : "^10.0.0",
"express" : "^4.18.2",
"express-validator" : "^7.0.1",
......@@ -44,38 +45,38 @@
"morgan" : "^1.10.0",
"multer" : "^1.4.5-lts.1",
"mysql" : "^2.18.1",
"node" : "^20.10.0",
"node" : "^20.11.0",
"parse-link-header" : "^2.0.0",
"semver" : "^7.5.4",
"semver" : "^7.6.0",
"swagger-ui-express" : "^5.0.0",
"tar-stream" : "^3.1.6",
"tar-stream" : "^3.1.7",
"uuid" : "^9.0.1",
"winston" : "^3.11.0",
"zod" : "^3.22.4",
"zod-validation-error": "^3.0.0"
},
"devDependencies": {
"@redocly/cli" : "^1.6.0",
"@redocly/cli" : "^1.8.2",
"@types/compression" : "^1.7.5",
"@types/cors" : "^2.8.17",
"@types/express" : "^4.17.21",
"@types/jsonwebtoken" : "^9.0.5",
"@types/morgan" : "^1.9.9",
"@types/multer" : "^1.4.11",
"@types/node" : "^20.11.5",
"@types/node" : "^20.11.17",
"@types/parse-link-header" : "^2.0.3",
"@types/semver" : "^7.5.6",
"@types/swagger-ui-express" : "^4.1.6",
"@types/tar-stream" : "^3.1.3",
"@types/uuid" : "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser" : "^6.18.1",
"@types/uuid" : "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser" : "^6.21.0",
"dotenv-cli" : "^7.3.0",
"dotenv-vault" : "^1.25.0",
"dotenv-vault" : "^1.26.0",
"genversion" : "^3.2.0",
"nodemon" : "^3.0.3",
"npm" : "^10.3.0",
"prisma" : "^5.8.1",
"npm" : "^10.4.0",
"prisma" : "^5.9.1",
"ts-node" : "^10.9.2",
"typescript" : "^5.3.3"
}
......
-- AlterTable
ALTER TABLE `Exercise` ADD COLUMN `correctionCommit` JSON NULL;
......@@ -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[]
......
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
import LazyVal from '../shared/helpers/LazyVal';
class DojoModelsHelper {
/**
* Get a full serializable object from a given object that contains LazyVal instances
*
* @param obj
* @param depth The depth of the search for LazyVal instances
*/
async getFullSerializableObject<T extends NonNullable<unknown>>(obj: T, depth: number = 0): Promise<unknown> {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const result: any = {};
for ( const key in obj ) {
let value: unknown = obj[key];
if ( value instanceof LazyVal ) {
value = await (obj[key] as LazyVal<unknown>).value;
}
if ( typeof value === 'object' && obj[key] !== null && depth > 0 ) {
result[key] = await this.getFullSerializableObject(value as NonNullable<unknown>, depth - 1);
} else {
result[key] = value;
}
}
return result;
}
}
export default new DojoModelsHelper();
\ No newline at end of file
......@@ -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();
}
});
}
});
}
......
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<Partial<Exercise>> | undefined> {
try {
return await db.exercise.findMany({
where : {
assignmentName : assignment.name,
correctionCommit: {
not: Prisma.JsonNull
}
},
include: {
assignment: false,
members : true,
results : false
}
}) as Array<Partial<Exercise>> ?? undefined;
} catch ( e ) {
return undefined;
}
}
export default Prisma.defineExtension(client => {
return client.$extends({
result: {
assignment: {
corrections: {
compute(assignment) {
return new LazyVal<Array<Partial<Exercise>> | undefined>(() => {
return getCorrections(assignment);
});
}
}
}
}
});
});
\ No newline at end of file
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
......@@ -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
......
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,
......
......@@ -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
......
......@@ -25,6 +25,7 @@ import path from 'path';
import SharedAssignmentHelper from '../shared/helpers/Dojo/SharedAssignmentHelper';
import GlobalHelper from '../helpers/GlobalHelper';
import DojoStatusCode from '../shared/types/Dojo/DojoStatusCode';
import DojoModelsHelper from '../helpers/DojoModelsHelper';
class AssignmentRoutes implements RoutesManager {
......@@ -45,43 +46,40 @@ 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
private async getAssignment(req: express.Request, res: express.Response) {
const assignment: Assignment | undefined = req.boundParams.assignment;
const assignment: Partial<Assignment> | undefined = req.boundParams.assignment;
if ( assignment && !assignment.published && !await AssignmentManager.isUserAllowedToAccessAssignment(assignment, req.session.profile) ) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if ( assignment && !assignment.published && !await AssignmentManager.isUserAllowedToAccessAssignment(assignment as Assignment, req.session.profile) ) {
delete assignment.gitlabId;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete assignment.gitlabLink;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete assignment.gitlabCreationInfo;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete assignment.gitlabLastInfo;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete assignment.gitlabLastInfoDate;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete assignment.staff;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete assignment.exercises;
}
return assignment ? req.session.sendResponse(res, StatusCodes.OK, assignment) : res.status(StatusCodes.NOT_FOUND).send();
return assignment ? req.session.sendResponse(res, StatusCodes.OK, DojoModelsHelper.getFullSerializableObject(assignment)) : res.status(StatusCodes.NOT_FOUND).send();
}
private async createAssignment(req: express.Request, res: express.Response) {
......@@ -171,14 +169,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 +203,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');
}
};
}
}
......
......@@ -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 {
......
Subproject commit 75f67b647da34337f3b220cacf78b2115d6022bc
Subproject commit 9e3f29d2f313ef96944a199da0db39f1827c496a
......@@ -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<Array<Exercise>>
}
export type Result = Prisma.ResultGetPayload<typeof resultBase>
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment