Merge fix/progress-bar into feat/232-assignments-pagina-ui-ux
This commit is contained in:
		
						commit
						368130c431
					
				
					 149 changed files with 4429 additions and 1120 deletions
				
			
		|  | @ -8,6 +8,7 @@ | ||||||
| ### Dwengo ### | ### Dwengo ### | ||||||
| 
 | 
 | ||||||
| DWENGO_PORT=3000 | DWENGO_PORT=3000 | ||||||
|  | DWENGO_RUN_MODE=test | ||||||
| 
 | 
 | ||||||
| DWENGO_DB_NAME=":memory:" | DWENGO_DB_NAME=":memory:" | ||||||
| DWENGO_DB_UPDATE=true | DWENGO_DB_UPDATE=true | ||||||
|  |  | ||||||
|  | @ -29,6 +29,7 @@ | ||||||
|         "cross-env": "^7.0.3", |         "cross-env": "^7.0.3", | ||||||
|         "dotenv": "^16.4.7", |         "dotenv": "^16.4.7", | ||||||
|         "express": "^5.0.1", |         "express": "^5.0.1", | ||||||
|  |         "express-fileupload": "^1.5.1", | ||||||
|         "express-jwt": "^8.5.1", |         "express-jwt": "^8.5.1", | ||||||
|         "gift-pegjs": "^1.0.2", |         "gift-pegjs": "^1.0.2", | ||||||
|         "isomorphic-dompurify": "^2.22.0", |         "isomorphic-dompurify": "^2.22.0", | ||||||
|  | @ -37,8 +38,11 @@ | ||||||
|         "jwks-rsa": "^3.1.0", |         "jwks-rsa": "^3.1.0", | ||||||
|         "loki-logger-ts": "^1.0.2", |         "loki-logger-ts": "^1.0.2", | ||||||
|         "marked": "^15.0.7", |         "marked": "^15.0.7", | ||||||
|  |         "mime-types": "^3.0.1", | ||||||
|  |         "nanoid": "^5.1.5", | ||||||
|         "response-time": "^2.3.3", |         "response-time": "^2.3.3", | ||||||
|         "swagger-ui-express": "^5.0.1", |         "swagger-ui-express": "^5.0.1", | ||||||
|  |         "unzipper": "^0.12.3", | ||||||
|         "uuid": "^11.1.0", |         "uuid": "^11.1.0", | ||||||
|         "winston": "^3.17.0", |         "winston": "^3.17.0", | ||||||
|         "winston-loki": "^6.1.3" |         "winston-loki": "^6.1.3" | ||||||
|  | @ -47,10 +51,13 @@ | ||||||
|         "@mikro-orm/cli": "6.4.12", |         "@mikro-orm/cli": "6.4.12", | ||||||
|         "@types/cors": "^2.8.17", |         "@types/cors": "^2.8.17", | ||||||
|         "@types/express": "^5.0.0", |         "@types/express": "^5.0.0", | ||||||
|  |         "@types/express-fileupload": "^1.5.1", | ||||||
|         "@types/js-yaml": "^4.0.9", |         "@types/js-yaml": "^4.0.9", | ||||||
|  |         "@types/mime-types": "^2.1.4", | ||||||
|         "@types/node": "^22.13.4", |         "@types/node": "^22.13.4", | ||||||
|         "@types/response-time": "^2.3.8", |         "@types/response-time": "^2.3.8", | ||||||
|         "@types/swagger-ui-express": "^4.1.8", |         "@types/swagger-ui-express": "^4.1.8", | ||||||
|  |         "@types/unzipper": "^0.10.11", | ||||||
|         "globals": "^15.15.0", |         "globals": "^15.15.0", | ||||||
|         "ts-node": "^10.9.2", |         "ts-node": "^10.9.2", | ||||||
|         "tsx": "^4.19.3", |         "tsx": "^4.19.3", | ||||||
|  |  | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; | import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; | ||||||
| import { getLogger } from '../logging/initalize.js'; | import { getLogger } from '../logging/initalize.js'; | ||||||
| import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; | import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; | ||||||
| import { createOrUpdateStudent } from '../services/students.js'; |  | ||||||
| import { createOrUpdateTeacher } from '../services/teachers.js'; |  | ||||||
| import { envVars, getEnvVar } from '../util/envVars.js'; | import { envVars, getEnvVar } from '../util/envVars.js'; | ||||||
| import { Response } from 'express'; | import { createOrUpdateStudent } from '../services/students.js'; | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { createOrUpdateTeacher } from '../services/teachers.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
| 
 | 
 | ||||||
| interface FrontendIdpConfig { | interface FrontendIdpConfig { | ||||||
|     authority: string; |     authority: string; | ||||||
|  | @ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig { | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function handleGetFrontendAuthConfig(_req: Request, res: Response): void { | ||||||
|  |     res.json(getFrontendAuthConfig()); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> { | export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||||
|     const auth = req.auth; |     const auth = req.auth; | ||||||
|     if (!auth) { |     if (!auth) { | ||||||
|  | @ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response) | ||||||
|         firstName: auth.firstName ?? '', |         firstName: auth.firstName ?? '', | ||||||
|         lastName: auth.lastName ?? '', |         lastName: auth.lastName ?? '', | ||||||
|     }; |     }; | ||||||
|     if (auth.accountType === 'student') { |     if (auth.accountType === AccountType.Student) { | ||||||
|         await createOrUpdateStudent(userData); |         await createOrUpdateStudent(userData); | ||||||
|         logger.debug(`Synchronized student ${userData.username} with IDP`); |         logger.debug(`Synchronized student ${userData.username} with IDP`); | ||||||
|     } else { |     } else { | ||||||
|  |  | ||||||
|  | @ -7,6 +7,9 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||||
| import { envVars, getEnvVar } from '../util/envVars.js'; | import { envVars, getEnvVar } from '../util/envVars.js'; | ||||||
| import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
|  | import { UploadedFile } from 'express-fileupload'; | ||||||
|  | import { AuthenticatedRequest } from '../middleware/auth/authenticated-request'; | ||||||
|  | import { requireFields } from './error-helper.js'; | ||||||
| 
 | 
 | ||||||
| function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { | function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { | ||||||
|     if (!req.params.hruid) { |     if (!req.params.hruid) { | ||||||
|  | @ -20,16 +23,23 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { | function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { | ||||||
|     if (!req.query.hruid) { |     const { hruid, language } = req.params; | ||||||
|         throw new BadRequestException('HRUID is required.'); |     requireFields({ hruid }); | ||||||
|     } | 
 | ||||||
|     return { |     return { | ||||||
|         hruid: req.params.hruid, |         hruid, | ||||||
|         language: (req.query.language as Language) || FALLBACK_LANG, |         language: (language as Language) || FALLBACK_LANG, | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { | export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { | ||||||
|  |     if (req.query.admin) { | ||||||
|  |         // If the admin query parameter is present, the user wants to have all learning objects with this admin.
 | ||||||
|  |         const learningObjects = await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string); | ||||||
|  | 
 | ||||||
|  |         res.json(learningObjects); | ||||||
|  |     } else { | ||||||
|  |         // Else he/she wants all learning objects on the path specified by the request parameters.
 | ||||||
|         const learningPathId = getLearningPathIdentifierFromRequest(req); |         const learningPathId = getLearningPathIdentifierFromRequest(req); | ||||||
|         const full = req.query.full; |         const full = req.query.full; | ||||||
| 
 | 
 | ||||||
|  | @ -42,6 +52,7 @@ export async function getAllLearningObjects(req: Request, res: Response): Promis | ||||||
| 
 | 
 | ||||||
|         res.json({ learningObjects: learningObjects }); |         res.json({ learningObjects: learningObjects }); | ||||||
|     } |     } | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| export async function getLearningObject(req: Request, res: Response): Promise<void> { | export async function getLearningObject(req: Request, res: Response): Promise<void> { | ||||||
|     const learningObjectId = getLearningObjectIdentifierFromRequest(req); |     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||||
|  | @ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise<void> | ||||||
|     } |     } | ||||||
|     res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); |     res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||||
|  |     if (!req.files || !req.files.learningObject) { | ||||||
|  |         throw new BadRequestException('No file uploaded'); | ||||||
|  |     } | ||||||
|  |     const learningObject = await learningObjectService.storeLearningObject((req.files.learningObject as UploadedFile).tempFilePath, [ | ||||||
|  |         req.auth!.username, | ||||||
|  |     ]); | ||||||
|  |     res.json(learningObject); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function handleDeleteLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||||
|  |     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||||
|  | 
 | ||||||
|  |     if (!learningObjectId.version) { | ||||||
|  |         throw new BadRequestException('When deleting a learning object, a version must be specified.'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const deletedLearningObject = await learningObjectService.deleteLearningObject({ | ||||||
|  |         hruid: learningObjectId.hruid, | ||||||
|  |         version: learningObjectId.version, | ||||||
|  |         language: learningObjectId.language, | ||||||
|  |     }); | ||||||
|  |     if (deletedLearningObject) { | ||||||
|  |         res.json(deletedLearningObject); | ||||||
|  |     } else { | ||||||
|  |         throw new NotFoundException('Learning object not found'); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -7,11 +7,20 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||||
| import { Group } from '../entities/assignments/group.entity.js'; | import { Group } from '../entities/assignments/group.entity.js'; | ||||||
| import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; | import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; | ||||||
|  | import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; | ||||||
|  | import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
|  | import { getTeacher } from '../services/teachers.js'; | ||||||
|  | import { requireFields } from './error-helper.js'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Fetch learning paths based on query parameters. |  * Fetch learning paths based on query parameters. | ||||||
|  */ |  */ | ||||||
| export async function getLearningPaths(req: Request, res: Response): Promise<void> { | export async function getLearningPaths(req: Request, res: Response): Promise<void> { | ||||||
|  |     const admin = req.query.admin; | ||||||
|  |     if (admin) { | ||||||
|  |         const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); | ||||||
|  |         res.json(paths); | ||||||
|  |     } else { | ||||||
|         const hruids = req.query.hruid; |         const hruids = req.query.hruid; | ||||||
|         const themeKey = req.query.theme as string; |         const themeKey = req.query.theme as string; | ||||||
|         const searchQuery = req.query.search as string; |         const searchQuery = req.query.search as string; | ||||||
|  | @ -52,6 +61,49 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi | ||||||
|             hruidList = themes.flatMap((theme) => theme.hruids); |             hruidList = themes.flatMap((theme) => theme.hruids); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|     const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); |         const learningPaths = await learningPathService.fetchLearningPaths( | ||||||
|  |             hruidList, | ||||||
|  |             language as Language, | ||||||
|  |             `HRUIDs: ${hruidList.join(', ')}`, | ||||||
|  |             forGroup | ||||||
|  |         ); | ||||||
|         res.json(learningPaths.data); |         res.json(learningPaths.data); | ||||||
|     } |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise<void> { | ||||||
|  |     return async (req, res) => { | ||||||
|  |         const path = req.body as LearningPath; | ||||||
|  |         const { hruid: hruidParam, language: languageParam } = req.params; | ||||||
|  | 
 | ||||||
|  |         if (isPut) { | ||||||
|  |             requireFields({ hruidParam, languageParam, path }); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const teacher = await getTeacher(req.auth!.username); | ||||||
|  |         if (isPut) { | ||||||
|  |             if (req.params.hruid !== path.hruid || req.params.language !== path.language) { | ||||||
|  |                 throw new BadRequestException('id_not_matching_query_params'); | ||||||
|  |             } | ||||||
|  |             await learningPathService.deleteLearningPath({ hruid: path.hruid, language: path.language as Language }); | ||||||
|  |         } | ||||||
|  |         res.json(await learningPathService.createNewLearningPath(path, [teacher])); | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const postLearningPath = postOrPutLearningPath(false); | ||||||
|  | export const putLearningPath = postOrPutLearningPath(true); | ||||||
|  | 
 | ||||||
|  | export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||||
|  |     const { hruid, language } = req.params; | ||||||
|  | 
 | ||||||
|  |     requireFields({ hruid, language }); | ||||||
|  | 
 | ||||||
|  |     const id: LearningPathIdentifier = { hruid, language: language as Language }; | ||||||
|  |     const deletedPath = await learningPathService.deleteLearningPath(id); | ||||||
|  |     if (deletedPath) { | ||||||
|  |         res.json(deletedPath); | ||||||
|  |     } else { | ||||||
|  |         throw new NotFoundException('The learning path could not be found.'); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response): | ||||||
|     const classId = req.body.classId; |     const classId = req.body.classId; | ||||||
|     requireFields({ username, classId }); |     requireFields({ username, classId }); | ||||||
| 
 | 
 | ||||||
|     const request = await createClassJoinRequest(username, classId); |     const request = await createClassJoinRequest(username, classId.toUpperCase()); | ||||||
|     res.json({ request }); |     res.json({ request }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -62,6 +62,11 @@ export async function getAllSubmissionsHandler(req: Request, res: Response): Pro | ||||||
| 
 | 
 | ||||||
| // TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden
 | // TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden
 | ||||||
| export async function createSubmissionHandler(req: Request, res: Response): Promise<void> { | export async function createSubmissionHandler(req: Request, res: Response): Promise<void> { | ||||||
|  |     const submitter = req.body.submitter; | ||||||
|  |     const usernameSubmitter = req.body.submitter.username; | ||||||
|  |     const group = req.body.group; | ||||||
|  |     requireFields({ group, submitter, usernameSubmitter }); | ||||||
|  | 
 | ||||||
|     const submissionDTO = req.body as SubmissionDTO; |     const submissionDTO = req.body as SubmissionDTO; | ||||||
|     const submission = await createSubmission(submissionDTO); |     const submission = await createSubmission(submissionDTO); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import { Request, Response } from 'express'; | ||||||
| import { requireFields } from './error-helper.js'; | import { requireFields } from './error-helper.js'; | ||||||
| import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; | import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; | ||||||
| import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; | import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; | ||||||
|  | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
| 
 | 
 | ||||||
| export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> { | export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> { | ||||||
|     const username = req.params.username; |     const username = req.params.username; | ||||||
|  | @ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom | ||||||
|     const classId = req.body.class; |     const classId = req.body.class; | ||||||
|     requireFields({ sender, receiver, classId }); |     requireFields({ sender, receiver, classId }); | ||||||
| 
 | 
 | ||||||
|  |     if (sender === receiver) { | ||||||
|  |         throw new ConflictException('Cannot send an invitation to yourself'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const data = req.body as TeacherInvitationData; |     const data = req.body as TeacherInvitationData; | ||||||
|     const invitation = await createInvitation(data); |     const invitation = await createInvitation(data); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -81,16 +81,6 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro | ||||||
|     res.json({ students }); |     res.json({ students }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> { |  | ||||||
|     const username = req.params.username; |  | ||||||
|     const full = req.query.full === 'true'; |  | ||||||
|     requireFields({ username }); |  | ||||||
| 
 |  | ||||||
|     const questions = await getTeacherQuestions(username, full); |  | ||||||
| 
 |  | ||||||
|     res.json({ questions }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> { | export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> { | ||||||
|     const classId = req.params.classId; |     const classId = req.params.classId; | ||||||
|     requireFields({ classId }); |     requireFields({ classId }); | ||||||
|  |  | ||||||
|  | @ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | import { Language } from '@dwengo-1/common/util/language'; | ||||||
| import { Teacher } from '../../entities/users/teacher.entity.js'; |  | ||||||
| 
 | 
 | ||||||
| export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | ||||||
|     public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { |     public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||||
|  | @ -13,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj | ||||||
|                 version: identifier.version, |                 version: identifier.version, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 populate: ['keywords'], |                 populate: ['keywords', 'admins'], | ||||||
|             } |             } | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  | @ -33,10 +32,22 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> { |     public async findAllByAdmin(adminUsername: string): Promise<LearningObject[]> { | ||||||
|         return this.find( |         return this.find( | ||||||
|             { admins: teacher }, |             { | ||||||
|  |                 admins: { | ||||||
|  |                     username: adminUsername, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|             { populate: ['admins'] } // Make sure to load admin relations
 |             { populate: ['admins'] } // Make sure to load admin relations
 | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||||
|  |         const learningObject = await this.findByIdentifier(identifier); | ||||||
|  |         if (learningObject) { | ||||||
|  |             await this.em.removeAndFlush(learningObject); | ||||||
|  |         } | ||||||
|  |         return learningObject; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,11 +4,10 @@ import { Language } from '@dwengo-1/common/util/language'; | ||||||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||||
| import { RequiredEntityData } from '@mikro-orm/core'; | import { RequiredEntityData } from '@mikro-orm/core'; | ||||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||||
| import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; |  | ||||||
| 
 | 
 | ||||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||||
|     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { |     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||||
|         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); |         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | @ -24,7 +23,21 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath> | ||||||
|                 language: language, |                 language: language, | ||||||
|                 $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], |                 $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], | ||||||
|             }, |             }, | ||||||
|             populate: ['nodes', 'nodes.transitions'], |             populate: ['nodes', 'nodes.transitions', 'admins'], | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Returns all learning paths which have the user with the given username as an administrator. | ||||||
|  |      */ | ||||||
|  |     public async findAllByAdminUsername(adminUsername: string): Promise<LearningPath[]> { | ||||||
|  |         return this.findAll({ | ||||||
|  |             where: { | ||||||
|  |                 admins: { | ||||||
|  |                     username: adminUsername, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             populate: ['nodes', 'nodes.transitions', 'admins'], | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -36,18 +49,15 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath> | ||||||
|         return this.em.create(LearningPathTransition, transitionData); |         return this.em.create(LearningPathTransition, transitionData); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async saveLearningPathNodesAndTransitions( |     /** | ||||||
|         path: LearningPath, |      * Deletes the learning path with the given hruid and language. | ||||||
|         nodes: LearningPathNode[], |      * @returns the deleted learning path or null if it was not found. | ||||||
|         transitions: LearningPathTransition[], |      */ | ||||||
|         options?: { preventOverwrite?: boolean } |     public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||||
|     ): Promise<void> { |         const path = await this.findByHruidAndLanguage(hruid, language); | ||||||
|         if (options?.preventOverwrite && (await this.findOne(path))) { |         if (path) { | ||||||
|             throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); |             await this.em.removeAndFlush(path); | ||||||
|         } |         } | ||||||
|         const em = this.getEntityManager(); |         return path; | ||||||
|         await em.persistAndFlush(path); |  | ||||||
|         await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); |  | ||||||
|         await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js'; | ||||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||||
| import { Student } from '../../entities/users/student.entity.js'; | import { Student } from '../../entities/users/student.entity.js'; | ||||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
|  | import { Group } from '../../entities/assignments/group.entity.js'; | ||||||
| import { Assignment } from '../../entities/assignments/assignment.entity.js'; | import { Assignment } from '../../entities/assignments/assignment.entity.js'; | ||||||
| import { Loaded } from '@mikro-orm/core'; | import { Loaded } from '@mikro-orm/core'; | ||||||
| import { Group } from '../../entities/assignments/group.entity'; |  | ||||||
| 
 | 
 | ||||||
| export class QuestionRepository extends DwengoEntityRepository<Question> { | export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||||
|     public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> { |     public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> { | ||||||
|  |  | ||||||
|  | @ -26,6 +26,9 @@ export class Assignment { | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     learningPathHruid!: string; |     learningPathHruid!: string; | ||||||
| 
 | 
 | ||||||
|  |     @Property({ type: 'datetime', nullable: true }) | ||||||
|  |     deadline?: Date; | ||||||
|  | 
 | ||||||
|     @Enum({ |     @Enum({ | ||||||
|         items: () => Language, |         items: () => Language, | ||||||
|     }) |     }) | ||||||
|  |  | ||||||
|  | @ -1,15 +1,17 @@ | ||||||
| import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; | import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { v4 } from 'uuid'; |  | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
| import { ClassRepository } from '../../data/classes/class-repository.js'; | import { ClassRepository } from '../../data/classes/class-repository.js'; | ||||||
|  | import { customAlphabet } from 'nanoid'; | ||||||
|  | 
 | ||||||
|  | const generateClassId = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6); | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ | ||||||
|     repository: () => ClassRepository, |     repository: () => ClassRepository, | ||||||
| }) | }) | ||||||
| export class Class { | export class Class { | ||||||
|     @PrimaryKey() |     @PrimaryKey() | ||||||
|     classId? = v4(); |     classId? = generateClassId(); | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     displayName!: string; |     displayName!: string; | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ export class Attachment { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => LearningObject, |         entity: () => LearningObject, | ||||||
|         primary: true, |         primary: true, | ||||||
|  |         deleteRule: 'cascade', | ||||||
|     }) |     }) | ||||||
|     learningObject!: LearningObject; |     learningObject!: LearningObject; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | import { ArrayType, Collection, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Attachment } from './attachment.entity.js'; | import { Attachment } from './attachment.entity.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | ||||||
|  | @ -28,7 +28,7 @@ export class LearningObject { | ||||||
|     @ManyToMany({ |     @ManyToMany({ | ||||||
|         entity: () => Teacher, |         entity: () => Teacher, | ||||||
|     }) |     }) | ||||||
|     admins!: Teacher[]; |     admins: Collection<Teacher> = new Collection<Teacher>(this); | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     title!: string; |     title!: string; | ||||||
|  | @ -84,7 +84,7 @@ export class LearningObject { | ||||||
|         entity: () => Attachment, |         entity: () => Attachment, | ||||||
|         mappedBy: 'learningObject', |         mappedBy: 'learningObject', | ||||||
|     }) |     }) | ||||||
|     attachments: Attachment[] = []; |     attachments: Collection<Attachment> = new Collection<Attachment>(this); | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'blob' }) |     @Property({ type: 'blob' }) | ||||||
|     content!: Buffer; |     content!: Buffer; | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | ||||||
| import { LearningPath } from './learning-path.entity.js'; | import { LearningPath } from './learning-path.entity.js'; | ||||||
| import { LearningPathTransition } from './learning-path-transition.entity.js'; | import { LearningPathTransition } from './learning-path-transition.entity.js'; | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | import { Language } from '@dwengo-1/common/util/language'; | ||||||
|  | @ -26,7 +26,7 @@ export class LearningPathNode { | ||||||
|     @Property({ type: 'bool' }) |     @Property({ type: 'bool' }) | ||||||
|     startNode!: boolean; |     startNode!: boolean; | ||||||
| 
 | 
 | ||||||
|     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) |     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] }) | ||||||
|     transitions!: Collection<LearningPathTransition>; |     transitions!: Collection<LearningPathTransition>; | ||||||
| 
 | 
 | ||||||
|     @Property({ length: 3 }) |     @Property({ length: 3 }) | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | import { Cascade, Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | ||||||
| import { LearningPathNode } from './learning-path-node.entity.js'; | import { LearningPathNode } from './learning-path-node.entity.js'; | ||||||
|  | @ -24,6 +24,6 @@ export class LearningPath { | ||||||
|     @Property({ type: 'blob', nullable: true }) |     @Property({ type: 'blob', nullable: true }) | ||||||
|     image: Buffer | null = null; |     image: Buffer | null = null; | ||||||
| 
 | 
 | ||||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) |     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] }) | ||||||
|     nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this); |     nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -20,6 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { | ||||||
|         description: assignment.description, |         description: assignment.description, | ||||||
|         learningPath: assignment.learningPathHruid, |         learningPath: assignment.learningPathHruid, | ||||||
|         language: assignment.learningPathLanguage, |         language: assignment.learningPathLanguage, | ||||||
|  |         deadline: assignment.deadline ?? new Date(), | ||||||
|         groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), |         groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  | @ -31,6 +32,7 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi | ||||||
|         description: assignmentData.description, |         description: assignmentData.description, | ||||||
|         learningPathHruid: assignmentData.learningPath, |         learningPathHruid: assignmentData.learningPath, | ||||||
|         learningPathLanguage: languageMap[assignmentData.language], |         learningPathLanguage: languageMap[assignmentData.language], | ||||||
|  |         deadline: assignmentData.deadline, | ||||||
|         groups: [], |         groups: [], | ||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO { | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function mapToUsername(user: { username: string }): string { | ||||||
|  |     return user.username; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T { | export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T { | ||||||
|     userInstance.username = userData.username; |     userInstance.username = userData.username; | ||||||
|     userInstance.firstName = userData.firstName; |     userInstance.firstName = userData.firstName; | ||||||
|  |  | ||||||
|  | @ -7,7 +7,6 @@ import * as express from 'express'; | ||||||
| import { AuthenticatedRequest } from './authenticated-request.js'; | import { AuthenticatedRequest } from './authenticated-request.js'; | ||||||
| import { AuthenticationInfo } from './authentication-info.js'; | import { AuthenticationInfo } from './authentication-info.js'; | ||||||
| import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; | import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; | ||||||
| import { ForbiddenException } from '../../exceptions/forbidden-exception.js'; |  | ||||||
| 
 | 
 | ||||||
| const JWKS_CACHE = true; | const JWKS_CACHE = true; | ||||||
| const JWKS_RATE_LIMIT = true; | const JWKS_RATE_LIMIT = true; | ||||||
|  | @ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; | export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill |  | ||||||
|  * the given access condition. |  | ||||||
|  * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates |  | ||||||
|  *                        to true. |  | ||||||
|  */ |  | ||||||
| export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) { |  | ||||||
|     return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => { |  | ||||||
|         if (!req.auth) { |  | ||||||
|             throw new UnauthorizedException(); |  | ||||||
|         } else if (!accessCondition(req.auth)) { |  | ||||||
|             throw new ForbiddenException(); |  | ||||||
|         } else { |  | ||||||
|             next(); |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Middleware which rejects all unauthenticated users, but accepts all authenticated users. |  | ||||||
|  */ |  | ||||||
| export const authenticatedOnly = authorize((_) => true); |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't students. |  | ||||||
|  */ |  | ||||||
| export const studentsOnly = authorize((auth) => auth.accountType === 'student'); |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't teachers. |  | ||||||
|  */ |  | ||||||
| export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); |  | ||||||
|  |  | ||||||
|  | @ -1,8 +1,15 @@ | ||||||
| import { Request } from 'express'; | import { Request } from 'express'; | ||||||
| import { JwtPayload } from 'jsonwebtoken'; | import { JwtPayload } from 'jsonwebtoken'; | ||||||
| import { AuthenticationInfo } from './authentication-info.js'; | import { AuthenticationInfo } from './authentication-info.js'; | ||||||
|  | import * as core from 'express-serve-static-core'; | ||||||
| 
 | 
 | ||||||
| export interface AuthenticatedRequest extends Request { | export interface AuthenticatedRequest< | ||||||
|  |     P = core.ParamsDictionary, | ||||||
|  |     ResBody = unknown, | ||||||
|  |     ReqBody = unknown, | ||||||
|  |     ReqQuery = core.Query, | ||||||
|  |     Locals extends Record<string, unknown> = Record<string, unknown>, | ||||||
|  | > extends Request<P, ResBody, ReqBody, ReqQuery, Locals> { | ||||||
|     // Properties are optional since the user is not necessarily authenticated.
 |     // Properties are optional since the user is not necessarily authenticated.
 | ||||||
|     jwtPayload?: JwtPayload; |     jwtPayload?: JwtPayload; | ||||||
|     auth?: AuthenticationInfo; |     auth?: AuthenticationInfo; | ||||||
|  |  | ||||||
							
								
								
									
										21
									
								
								backend/src/middleware/auth/checks/assignment-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								backend/src/middleware/auth/checks/assignment-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { fetchClass } from '../../../services/classes.js'; | ||||||
|  | import { fetchAllGroups } from '../../../services/groups.js'; | ||||||
|  | import { mapToUsername } from '../../../interfaces/user.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment). | ||||||
|  |  * Only allows requests from users who are | ||||||
|  |  * - either teachers of the class the assignment was posted in, | ||||||
|  |  * - or students in a group of the assignment. | ||||||
|  |  */ | ||||||
|  | export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => { | ||||||
|  |     const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number }; | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         const clazz = await fetchClass(classId); | ||||||
|  |         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||||
|  |     } | ||||||
|  |     const groups = await fetchAllGroups(classId, assignmentId); | ||||||
|  |     return groups.some((group) => group.members.map((member) => member.username).includes(auth.username)); | ||||||
|  | }); | ||||||
							
								
								
									
										61
									
								
								backend/src/middleware/auth/checks/auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/middleware/auth/checks/auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | import * as express from 'express'; | ||||||
|  | import { RequestHandler } from 'express'; | ||||||
|  | import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js'; | ||||||
|  | import { ForbiddenException } from '../../../exceptions/forbidden-exception.js'; | ||||||
|  | import { envVars, getEnvVar } from '../../../util/envVars.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill | ||||||
|  |  * the given access condition. | ||||||
|  |  * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates | ||||||
|  |  *                        to true. | ||||||
|  |  */ | ||||||
|  | export function authorize<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>( | ||||||
|  |     accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean> | ||||||
|  | ): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> { | ||||||
|  |     // Bypass authentication during testing
 | ||||||
|  |     if (getEnvVar(envVars.RunMode) === 'test') { | ||||||
|  |         return async ( | ||||||
|  |             _req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>, | ||||||
|  |             _res: express.Response, | ||||||
|  |             next: express.NextFunction | ||||||
|  |         ): Promise<void> => { | ||||||
|  |             next(); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return async ( | ||||||
|  |         req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>, | ||||||
|  |         _res: express.Response, | ||||||
|  |         next: express.NextFunction | ||||||
|  |     ): Promise<void> => { | ||||||
|  |         if (!req.auth) { | ||||||
|  |             throw new UnauthorizedException(); | ||||||
|  |         } else if (!(await accessCondition(req.auth, req))) { | ||||||
|  |             throw new ForbiddenException(); | ||||||
|  |         } else { | ||||||
|  |             next(); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects all unauthenticated users, but accepts all authenticated users. | ||||||
|  |  */ | ||||||
|  | export const authenticatedOnly = authorize((_) => true); | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects requests from unauthenticated users or users that aren't students. | ||||||
|  |  */ | ||||||
|  | export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student); | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects requests from unauthenticated users or users that aren't teachers. | ||||||
|  |  */ | ||||||
|  | export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher); | ||||||
|  | /** | ||||||
|  |  * Middleware which is to be used on requests no normal user should be able to execute. | ||||||
|  |  * Since there is no concept of administrator accounts yet, currently, those requests will always be blocked. | ||||||
|  |  */ | ||||||
|  | export const adminOnly = authorize(() => false); | ||||||
							
								
								
									
										70
									
								
								backend/src/middleware/auth/checks/class-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								backend/src/middleware/auth/checks/class-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | import { fetchClass } from '../../../services/classes.js'; | ||||||
|  | import { mapToUsername } from '../../../interfaces/user.js'; | ||||||
|  | import { getAllInvitations } from '../../../services/teacher-invitations.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | async function teaches(teacherUsername: string, classId: string): Promise<boolean> { | ||||||
|  |     const clazz = await fetchClass(classId); | ||||||
|  |     return clazz.teachers.map(mapToUsername).includes(teacherUsername); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * To be used on a request with path parameters username and classId. | ||||||
|  |  * Only allows requests whose username parameter is equal to the username of the user who is logged in and requests | ||||||
|  |  * whose classId parameter references a class the logged-in user is a teacher of. | ||||||
|  |  */ | ||||||
|  | export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     if (req.params.username === auth.username) { | ||||||
|  |         return true; | ||||||
|  |     } else if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         return teaches(auth.username, req.params.classId); | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Only let the request pass through if its path parameter "username" is the username of the currently logged-in | ||||||
|  |  * teacher and the path parameter "classId" refers to a class the teacher teaches. | ||||||
|  |  */ | ||||||
|  | export const onlyAllowTeacherOfClass = authorize( | ||||||
|  |     async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId) | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Only let the request pass through if the class id in it refers to a class the current user is in (as a student | ||||||
|  |  * or teacher) | ||||||
|  |  */ | ||||||
|  | export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const classId = req.params.classId ?? req.params.classid ?? req.params.id; | ||||||
|  |     const clazz = await fetchClass(classId); | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||||
|  |     } | ||||||
|  |     return clazz.students.map(mapToUsername).includes(auth.username); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const classId = req.params.classId ?? req.params.classid ?? req.params.id; | ||||||
|  |     const clazz = await fetchClass(classId); | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         const invitations = await getAllInvitations(auth.username, false); | ||||||
|  |         return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId); | ||||||
|  |     } | ||||||
|  |     return clazz.students.map(mapToUsername).includes(auth.username); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Only allows the request to pass if the 'class' property in its body is a class the current user is a member of. | ||||||
|  |  */ | ||||||
|  | export const onlyAllowOwnClassInBody = authorize(async (auth, req) => { | ||||||
|  |     const classId = (req.body as { class: string })?.class; | ||||||
|  |     const clazz = await fetchClass(classId); | ||||||
|  | 
 | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||||
|  |     } | ||||||
|  |     return clazz.students.map(mapToUsername).includes(auth.username); | ||||||
|  | }); | ||||||
							
								
								
									
										26
									
								
								backend/src/middleware/auth/checks/group-auth-checker.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/middleware/auth/checks/group-auth-checker.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { fetchClass } from '../../../services/classes.js'; | ||||||
|  | import { fetchGroup } from '../../../services/groups.js'; | ||||||
|  | import { mapToUsername } from '../../../interfaces/user.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'. | ||||||
|  |  * Only allows requests from users who are | ||||||
|  |  * - either teachers of the class the assignment for the group was posted in, | ||||||
|  |  * - or students in the group | ||||||
|  |  */ | ||||||
|  | export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => { | ||||||
|  |     const { | ||||||
|  |         classid: classId, | ||||||
|  |         assignmentid: assignmentId, | ||||||
|  |         groupid: groupId, | ||||||
|  |     } = req.params as { classid: string; assignmentid: number; groupid: number }; | ||||||
|  | 
 | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         const clazz = await fetchClass(classId); | ||||||
|  |         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||||
|  |     } // User is student
 | ||||||
|  |     const group = await fetchGroup(classId, assignmentId, groupId); | ||||||
|  |     return group.members.map(mapToUsername).includes(auth.username); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,21 @@ | ||||||
|  | import { authorize } from './auth-checks'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId') | ||||||
|  |  * are | ||||||
|  |  * - either not set | ||||||
|  |  * - or set to a group the user is in, | ||||||
|  |  * - or set to anything if the user is a teacher. | ||||||
|  |  */ | ||||||
|  | export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const { forGroup, assignmentNo, classId } = req.params; | ||||||
|  |     if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) { | ||||||
|  |         // TODO: groupNumber?
 | ||||||
|  |         // Const group = await fetchGroup(Number(classId), Number(assignmentNo), )
 | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     return true; | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,16 @@ | ||||||
|  | import { Language } from '@dwengo-1/common/util/language'; | ||||||
|  | import learningObjectService from '../../../services/learning-objects/learning-object-service.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | 
 | ||||||
|  | export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const { hruid } = req.params; | ||||||
|  |     const { version, language } = req.query; | ||||||
|  |     const admins = await learningObjectService.getAdmins({ | ||||||
|  |         hruid, | ||||||
|  |         language: language as Language, | ||||||
|  |         version: parseInt(version as string), | ||||||
|  |     }); | ||||||
|  |     return admins.includes(auth.username); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,13 @@ | ||||||
|  | import { Language } from '@dwengo-1/common/util/language'; | ||||||
|  | import learningPathService from '../../../services/learning-paths/learning-path-service.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | 
 | ||||||
|  | export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const adminsForLearningPath = await learningPathService.getAdmins({ | ||||||
|  |         hruid: req.params.hruid, | ||||||
|  |         language: req.params.language as Language, | ||||||
|  |     }); | ||||||
|  |     return adminsForLearningPath && adminsForLearningPath.includes(auth.username); | ||||||
|  | }); | ||||||
							
								
								
									
										66
									
								
								backend/src/middleware/auth/checks/question-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								backend/src/middleware/auth/checks/question-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | import { requireFields } from '../../../controllers/error-helper.js'; | ||||||
|  | import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js'; | ||||||
|  | import { fetchQuestion } from '../../../services/questions.js'; | ||||||
|  | import { FALLBACK_SEQ_NUM } from '../../../config.js'; | ||||||
|  | import { fetchAnswer } from '../../../services/answers.js'; | ||||||
|  | import { mapToUsername } from '../../../interfaces/user.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | export const onlyAllowAuthor = authorize( | ||||||
|  |     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const hruid = req.params.hruid; | ||||||
|  |     const version = req.params.version; | ||||||
|  |     const language = req.query.lang as string; | ||||||
|  |     const seq = req.params.seq; | ||||||
|  |     requireFields({ hruid }); | ||||||
|  | 
 | ||||||
|  |     const learningObjectId = getLearningObjectId(hruid, version, language); | ||||||
|  |     const questionId = getQuestionId(learningObjectId, seq); | ||||||
|  | 
 | ||||||
|  |     const question = await fetchQuestion(questionId); | ||||||
|  | 
 | ||||||
|  |     return question.author.username === auth.username; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const hruid = req.params.hruid; | ||||||
|  |     const version = req.params.version; | ||||||
|  |     const language = req.query.lang as string; | ||||||
|  |     const seq = req.params.seq; | ||||||
|  |     const seqAnswer = req.params.seqAnswer; | ||||||
|  |     requireFields({ hruid }); | ||||||
|  | 
 | ||||||
|  |     const learningObjectId = getLearningObjectId(hruid, version, language); | ||||||
|  |     const questionId = getQuestionId(learningObjectId, seq); | ||||||
|  | 
 | ||||||
|  |     const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM; | ||||||
|  |     const answer = await fetchAnswer(questionId, sequenceNumber); | ||||||
|  | 
 | ||||||
|  |     return answer.author.username === auth.username; | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const hruid = req.params.hruid; | ||||||
|  |     const version = req.params.version; | ||||||
|  |     const language = req.query.lang as string; | ||||||
|  |     const seq = req.params.seq; | ||||||
|  |     requireFields({ hruid }); | ||||||
|  | 
 | ||||||
|  |     const learningObjectId = getLearningObjectId(hruid, version, language); | ||||||
|  |     const questionId = getQuestionId(learningObjectId, seq); | ||||||
|  | 
 | ||||||
|  |     const question = await fetchQuestion(questionId); | ||||||
|  |     const group = question.inGroup; | ||||||
|  | 
 | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         const cls = group.assignment.within; // TODO check if contains full objects
 | ||||||
|  |         return cls.teachers.map(mapToUsername).includes(auth.username); | ||||||
|  |     } // User is student
 | ||||||
|  |     return group.members.map(mapToUsername).includes(auth.username); | ||||||
|  | }); | ||||||
							
								
								
									
										28
									
								
								backend/src/middleware/auth/checks/submission-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								backend/src/middleware/auth/checks/submission-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | import { languageMap } from '@dwengo-1/common/util/language'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js'; | ||||||
|  | import { fetchSubmission } from '../../../services/submissions.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { FALLBACK_LANG } from '../../../config.js'; | ||||||
|  | import { mapToUsername } from '../../../interfaces/user.js'; | ||||||
|  | import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
|  | 
 | ||||||
|  | export const onlyAllowSubmitter = authorize( | ||||||
|  |     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|  |     const { hruid: lohruid, id: submissionNumber } = req.params; | ||||||
|  |     const { language: lang, version: version } = req.query; | ||||||
|  | 
 | ||||||
|  |     const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version)); | ||||||
|  |     const submission = await fetchSubmission(loId, Number(submissionNumber)); | ||||||
|  | 
 | ||||||
|  |     if (auth.accountType === AccountType.Teacher) { | ||||||
|  |         // Dit kan niet werken om dat al deze objecten niet gepopulate zijn.
 | ||||||
|  |         return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | 
 | ||||||
|  | export const onlyAllowSenderOrReceiver = authorize( | ||||||
|  |     (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowSenderBody = authorize( | ||||||
|  |     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export const onlyAllowReceiverBody = authorize( | ||||||
|  |     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username | ||||||
|  | ); | ||||||
							
								
								
									
										8
									
								
								backend/src/middleware/auth/checks/user-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								backend/src/middleware/auth/checks/user-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | ||||||
|  | import { authorize } from './auth-checks.js'; | ||||||
|  | import { AuthenticationInfo } from '../authentication-info.js'; | ||||||
|  | import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Only allow the user whose username is in the path parameter "username" to access the endpoint. | ||||||
|  |  */ | ||||||
|  | export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username); | ||||||
|  | @ -1,16 +1,18 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; | import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; | ||||||
|  | import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| router.get('/', getAllAnswersHandler); | router.get('/', authenticatedOnly, getAllAnswersHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createAnswerHandler); | router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:seqAnswer', getAnswerHandler); | router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:seqAnswer', deleteAnswerHandler); | router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler); | ||||||
| 
 | 
 | ||||||
| router.put('/:seqAnswer', updateAnswerHandler); | router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -9,22 +9,25 @@ import { | ||||||
|     putAssignmentHandler, |     putAssignmentHandler, | ||||||
| } from '../controllers/assignments.js'; | } from '../controllers/assignments.js'; | ||||||
| import groupRouter from './groups.js'; | import groupRouter from './groups.js'; | ||||||
|  | import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js'; | ||||||
|  | import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| router.get('/', getAllAssignmentsHandler); | router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createAssignmentHandler); | router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id', getAssignmentHandler); | router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler); | ||||||
| 
 | 
 | ||||||
| router.put('/:id', putAssignmentHandler); | router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:id', deleteAssignmentHandler); | router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id/submissions', getAssignmentsSubmissionsHandler); | router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id/questions', getAssignmentQuestionsHandler); | router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler); | ||||||
| 
 | 
 | ||||||
| router.use('/:assignmentid/groups', groupRouter); | router.use('/:assignmentid/groups', groupRouter); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,28 +1,35 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; | import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; | ||||||
| import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; | import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Returns auth configuration for frontend
 | // Returns auth configuration for frontend
 | ||||||
| router.get('/config', (_req, res) => { | router.get('/config', handleGetFrontendAuthConfig); | ||||||
|     res.json(getFrontendAuthConfig()); |  | ||||||
| }); |  | ||||||
| 
 | 
 | ||||||
| router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { | router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { | ||||||
|     /* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ |     /* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|     res.json({ message: 'If you see this, you should be authenticated!' }); |     res.json({ message: 'If you see this, you should be authenticated!' }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| router.get('/testStudentsOnly', studentsOnly, (_req, res) => { | router.get('/testStudentsOnly', studentsOnly, (_req, res) => { | ||||||
|     /* #swagger.security = [{ "student": [ ] }] */ |     /* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */ | ||||||
|     res.json({ message: 'If you see this, you should be a student!' }); |     res.json({ message: 'If you see this, you should be a student!' }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| router.get('/testTeachersOnly', teachersOnly, (_req, res) => { | router.get('/testTeachersOnly', teachersOnly, (_req, res) => { | ||||||
|     /* #swagger.security = [{ "teacher": [ ] }] */ |     /* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|     res.json({ message: 'If you see this, you should be a teacher!' }); |     res.json({ message: 'If you see this, you should be a teacher!' }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| router.post('/hello', authenticatedOnly, postHelloHandler); | // This endpoint is called by the client when the user has just logged in.
 | ||||||
|  | // It creates or updates the user entity based on the authentication data the endpoint was called with.
 | ||||||
|  | router.post( | ||||||
|  |     '/hello', | ||||||
|  |     authenticatedOnly, | ||||||
|  |     /* | ||||||
|  |     #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] | ||||||
|  | */ postHelloHandler | ||||||
|  | ); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -14,33 +14,35 @@ import { | ||||||
|     putClassHandler, |     putClassHandler, | ||||||
| } from '../controllers/classes.js'; | } from '../controllers/classes.js'; | ||||||
| import assignmentRouter from './assignments.js'; | import assignmentRouter from './assignments.js'; | ||||||
|  | import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Root endpoint used to search objects
 | router.get('/', adminOnly, getAllClassesHandler); | ||||||
| router.get('/', getAllClassesHandler); |  | ||||||
| 
 | 
 | ||||||
| router.post('/', createClassHandler); | router.post('/', teachersOnly, createClassHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id', getClassHandler); | router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler); | ||||||
| 
 | 
 | ||||||
| router.put('/:id', putClassHandler); | router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:id', deleteClassHandler); | router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); | router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id/students', getClassStudentsHandler); | router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/:id/students', addClassStudentHandler); | router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:id/students/:username', deleteClassStudentHandler); | router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:id/teachers', getClassTeachersHandler); | router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/:id/teachers', addClassTeacherHandler); | // De combinatie van deze POST en DELETE endpoints kan lethal zijn
 | ||||||
|  | router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:id/teachers/:username', deleteClassTeacherHandler); | router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler); | ||||||
| 
 | 
 | ||||||
| router.use('/:classid/assignments', assignmentRouter); | router.use('/:classid/assignments', assignmentRouter); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -8,22 +8,24 @@ import { | ||||||
|     getGroupSubmissionsHandler, |     getGroupSubmissionsHandler, | ||||||
|     putGroupHandler, |     putGroupHandler, | ||||||
| } from '../controllers/groups.js'; | } from '../controllers/groups.js'; | ||||||
|  | import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js'; | ||||||
|  | import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| // Root endpoint used to search objects
 | router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler); | ||||||
| router.get('/', getAllGroupsHandler); |  | ||||||
| 
 | 
 | ||||||
| router.post('/', createGroupHandler); | router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:groupid', getGroupHandler); | router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler); | ||||||
| 
 | 
 | ||||||
| router.put('/:groupid', putGroupHandler); | router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:groupid', deleteGroupHandler); | router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:groupid/submissions', getGroupSubmissionsHandler); | router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:groupid/questions', getGroupQuestionsHandler); | router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -1,8 +1,17 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; | import { | ||||||
| 
 |     getAllLearningObjects, | ||||||
|  |     getAttachment, | ||||||
|  |     getLearningObject, | ||||||
|  |     getLearningObjectHTML, | ||||||
|  |     handleDeleteLearningObject, | ||||||
|  |     handlePostLearningObject, | ||||||
|  | } from '../controllers/learning-objects.js'; | ||||||
| import submissionRoutes from './submissions.js'; | import submissionRoutes from './submissions.js'; | ||||||
| import questionRoutes from './questions.js'; | import questionRoutes from './questions.js'; | ||||||
|  | import fileUpload from 'express-fileupload'; | ||||||
|  | import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; | ||||||
|  | import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
|  | @ -16,13 +25,21 @@ const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Route 2: list of object data
 | // Route 2: list of object data
 | ||||||
| // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
 | // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
 | ||||||
| router.get('/', getAllLearningObjects); | router.get('/', authenticatedOnly, getAllLearningObjects); | ||||||
|  | 
 | ||||||
|  | router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject); | ||||||
| 
 | 
 | ||||||
| // Parameter: hruid of learning object
 | // Parameter: hruid of learning object
 | ||||||
| // Query: language
 | // Query: language
 | ||||||
| // Route to fetch data of one learning object based on its hruid
 | // Route to fetch data of one learning object based on its hruid
 | ||||||
| // Example: http://localhost:3000/learningObject/un_ai7
 | // Example: http://localhost:3000/learningObject/un_ai7
 | ||||||
| router.get('/:hruid', getLearningObject); | router.get('/:hruid', authenticatedOnly, getLearningObject); | ||||||
|  | 
 | ||||||
|  | // Parameter: hruid of learning object
 | ||||||
|  | // Query: language
 | ||||||
|  | // Route to delete a learning object based on its hruid.
 | ||||||
|  | // Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1
 | ||||||
|  | router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject); | ||||||
| 
 | 
 | ||||||
| router.use('/:hruid/submissions', submissionRoutes); | router.use('/:hruid/submissions', submissionRoutes); | ||||||
| 
 | 
 | ||||||
|  | @ -32,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes); | ||||||
| // Query: language, version (optional)
 | // Query: language, version (optional)
 | ||||||
| // Route to fetch the HTML rendering of one learning object based on its hruid.
 | // Route to fetch the HTML rendering of one learning object based on its hruid.
 | ||||||
| // Example: http://localhost:3000/learningObject/un_ai7/html
 | // Example: http://localhost:3000/learningObject/un_ai7/html
 | ||||||
| router.get('/:hruid/html', getLearningObjectHTML); | router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML); | ||||||
| 
 | 
 | ||||||
| // Parameter: hruid of learning object, name of attachment.
 | // Parameter: hruid of learning object, name of attachment.
 | ||||||
| // Query: language, version (optional).
 | // Query: language, version (optional).
 | ||||||
| // Route to get the raw data of the attachment for one learning object based on its hruid.
 | // Route to get the raw data of the attachment for one learning object based on its hruid.
 | ||||||
| // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
 | // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
 | ||||||
| router.get('/:hruid/html/:attachmentName', getAttachment); | router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -1,5 +1,7 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getLearningPaths } from '../controllers/learning-paths.js'; | import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; | ||||||
|  | import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
|  | @ -22,6 +24,10 @@ const router = express.Router(); | ||||||
| // Route to fetch learning paths based on a theme
 | // Route to fetch learning paths based on a theme
 | ||||||
| // Example: http://localhost:3000/learningPath?theme=kiks
 | // Example: http://localhost:3000/learningPath?theme=kiks
 | ||||||
| 
 | 
 | ||||||
| router.get('/', getLearningPaths); | router.get('/', authenticatedOnly, getLearningPaths); | ||||||
|  | router.post('/', teachersOnly, postLearningPath); | ||||||
|  | 
 | ||||||
|  | router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath); | ||||||
|  | router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -1,20 +1,25 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; | import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; | ||||||
| import answerRoutes from './answers.js'; | import answerRoutes from './answers.js'; | ||||||
|  | import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { updateAnswerHandler } from '../controllers/answers.js'; | ||||||
|  | import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| // Query language
 | // Query language
 | ||||||
| 
 | 
 | ||||||
| // Root endpoint used to search objects
 | // Root endpoint used to search objects
 | ||||||
| router.get('/', getAllQuestionsHandler); | router.get('/', authenticatedOnly, getAllQuestionsHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createQuestionHandler); | router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler); | ||||||
| 
 |  | ||||||
| router.delete('/:seq', deleteQuestionHandler); |  | ||||||
| 
 | 
 | ||||||
| // Information about a question with id
 | // Information about a question with id
 | ||||||
| router.get('/:seq', getQuestionHandler); | router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler); | ||||||
|  | 
 | ||||||
|  | router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler); | ||||||
|  | 
 | ||||||
|  | router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler); | ||||||
| 
 | 
 | ||||||
| router.use('/:seq/answers', answerRoutes); | router.use('/:seq/answers', answerRoutes); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -18,12 +18,30 @@ router.get('/', (_, res: Response) => { | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); |  | ||||||
| router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); |  | ||||||
| router.use('/class', classRouter /* #swagger.tags = ['Class'] */); |  | ||||||
| router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); | router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); | ||||||
| router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); | router.use( | ||||||
| router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); |     '/class', | ||||||
| router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); |     classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|  | ); | ||||||
|  | router.use( | ||||||
|  |     '/learningObject', | ||||||
|  |     learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|  | ); | ||||||
|  | router.use( | ||||||
|  |     '/learningPath', | ||||||
|  |     learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|  | ); | ||||||
|  | router.use( | ||||||
|  |     '/student', | ||||||
|  |     studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|  | ); | ||||||
|  | router.use( | ||||||
|  |     '/teacher', | ||||||
|  |     teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|  | ); | ||||||
|  | router.use( | ||||||
|  |     '/theme', | ||||||
|  |     themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||||
|  | ); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -5,15 +5,19 @@ import { | ||||||
|     getStudentRequestHandler, |     getStudentRequestHandler, | ||||||
|     getStudentRequestsHandler, |     getStudentRequestsHandler, | ||||||
| } from '../controllers/students.js'; | } from '../controllers/students.js'; | ||||||
|  | import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||||
|  | import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js'; | ||||||
|  | 
 | ||||||
|  | // Under /:username/joinRequests/
 | ||||||
| 
 | 
 | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| router.get('/', getStudentRequestsHandler); | router.get('/', preventImpersonation, getStudentRequestsHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createStudentRequestHandler); | router.post('/', preventImpersonation, createStudentRequestHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:classId', getStudentRequestHandler); | router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:classId', deleteClassJoinRequestHandler); | router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -11,33 +11,37 @@ import { | ||||||
|     getStudentSubmissionsHandler, |     getStudentSubmissionsHandler, | ||||||
| } from '../controllers/students.js'; | } from '../controllers/students.js'; | ||||||
| import joinRequestRouter from './student-join-requests.js'; | import joinRequestRouter from './student-join-requests.js'; | ||||||
|  | import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||||
|  | import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Root endpoint used to search objects
 | // Root endpoint used to search objects
 | ||||||
| router.get('/', getAllStudentsHandler); | router.get('/', adminOnly, getAllStudentsHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createStudentHandler); | // Users will be created automatically when some resource is created for them. Therefore, this endpoint
 | ||||||
|  | // Can only be used by an administrator.
 | ||||||
|  | router.post('/', adminOnly, createStudentHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:username', deleteStudentHandler); | router.delete('/:username', preventImpersonation, deleteStudentHandler); | ||||||
| 
 | 
 | ||||||
| // Information about a student's profile
 | // Information about a student's profile
 | ||||||
| router.get('/:username', getStudentHandler); | router.get('/:username', preventImpersonation, getStudentHandler); | ||||||
| 
 | 
 | ||||||
| // The list of classes a student is in
 | // The list of classes a student is in
 | ||||||
| router.get('/:username/classes', getStudentClassesHandler); | router.get('/:username/classes', preventImpersonation, getStudentClassesHandler); | ||||||
| 
 | 
 | ||||||
| // The list of submissions a student has made
 | // The list of submissions a student has made
 | ||||||
| router.get('/:username/submissions', getStudentSubmissionsHandler); | router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler); | ||||||
| 
 | 
 | ||||||
| // The list of assignments a student has
 | // The list of assignments a student has
 | ||||||
| router.get('/:username/assignments', getStudentAssignmentsHandler); | router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler); | ||||||
| 
 | 
 | ||||||
| // The list of groups a student is in
 | // The list of groups a student is in
 | ||||||
| router.get('/:username/groups', getStudentGroupsHandler); | router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler); | ||||||
| 
 | 
 | ||||||
| // A list of questions a user has created
 | // A list of questions a user has created
 | ||||||
| router.get('/:username/questions', getStudentQuestionsHandler); | router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler); | ||||||
| 
 | 
 | ||||||
| router.use('/:username/joinRequests', joinRequestRouter); | router.use('/:username/joinRequests', joinRequestRouter); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,15 +1,15 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; | import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; | ||||||
|  | import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js'; | ||||||
|  | import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| // Root endpoint used to search objects
 | router.get('/', adminOnly, getSubmissionsHandler); | ||||||
| router.get('/', getSubmissionsHandler); |  | ||||||
| 
 | 
 | ||||||
| router.post('/', createSubmissionHandler); | router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler); | ||||||
| 
 | 
 | ||||||
| // Information about an submission with id 'id'
 | router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler); | ||||||
| router.get('/:id', getSubmissionHandler); |  | ||||||
| 
 | 
 | ||||||
| router.delete('/:id', deleteSubmissionHandler); | router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -6,17 +6,24 @@ import { | ||||||
|     getInvitationHandler, |     getInvitationHandler, | ||||||
|     updateInvitationHandler, |     updateInvitationHandler, | ||||||
| } from '../controllers/teacher-invitations.js'; | } from '../controllers/teacher-invitations.js'; | ||||||
|  | import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||||
|  | import { | ||||||
|  |     onlyAllowReceiverBody, | ||||||
|  |     onlyAllowSender, | ||||||
|  |     onlyAllowSenderBody, | ||||||
|  |     onlyAllowSenderOrReceiver, | ||||||
|  | } from '../middleware/auth/checks/teacher-invitation-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router({ mergeParams: true }); | const router = express.Router({ mergeParams: true }); | ||||||
| 
 | 
 | ||||||
| router.get('/:username', getAllInvitationsHandler); | router.get('/:username', preventImpersonation, getAllInvitationsHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:sender/:receiver/:classId', getInvitationHandler); | router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createInvitationHandler); | router.post('/', onlyAllowSenderBody, createInvitationHandler); | ||||||
| 
 | 
 | ||||||
| router.put('/', updateInvitationHandler); | router.put('/', onlyAllowReceiverBody, updateInvitationHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); | router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -7,34 +7,34 @@ import { | ||||||
|     getTeacherAssignmentsHandler, |     getTeacherAssignmentsHandler, | ||||||
|     getTeacherClassHandler, |     getTeacherClassHandler, | ||||||
|     getTeacherHandler, |     getTeacherHandler, | ||||||
|     getTeacherQuestionHandler, |  | ||||||
|     getTeacherStudentHandler, |     getTeacherStudentHandler, | ||||||
|     updateStudentJoinRequestHandler, |     updateStudentJoinRequestHandler, | ||||||
| } from '../controllers/teachers.js'; | } from '../controllers/teachers.js'; | ||||||
| import invitationRouter from './teacher-invitations.js'; | import invitationRouter from './teacher-invitations.js'; | ||||||
| 
 | import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
|  | import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||||
|  | import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js'; | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Root endpoint used to search objects
 | // Root endpoint used to search objects
 | ||||||
| router.get('/', getAllTeachersHandler); | router.get('/', adminOnly, getAllTeachersHandler); | ||||||
| 
 | 
 | ||||||
| router.post('/', createTeacherHandler); | router.post('/', adminOnly, createTeacherHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:username', getTeacherHandler); | router.get('/:username', preventImpersonation, getTeacherHandler); | ||||||
| 
 | 
 | ||||||
| router.delete('/:username', deleteTeacherHandler); | router.delete('/:username', preventImpersonation, deleteTeacherHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:username/classes', getTeacherClassHandler); | router.get('/:username/classes', preventImpersonation, getTeacherClassHandler); | ||||||
|  | 
 | ||||||
|  | router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); | ||||||
| 
 | 
 | ||||||
| router.get(`/:username/assignments`, getTeacherAssignmentsHandler); | router.get(`/:username/assignments`, getTeacherAssignmentsHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:username/students', getTeacherStudentHandler); |  | ||||||
| 
 | 
 | ||||||
| router.get('/:username/questions', getTeacherQuestionHandler); | router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); | router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); | ||||||
| 
 |  | ||||||
| router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); |  | ||||||
| 
 | 
 | ||||||
| // Invitations to other classes a teacher received
 | // Invitations to other classes a teacher received
 | ||||||
| router.use('/invitations', invitationRouter); | router.use('/invitations', invitationRouter); | ||||||
|  |  | ||||||
|  | @ -1,14 +1,15 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; | import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; | ||||||
|  | import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
| // Query: language
 | // Query: language
 | ||||||
| //  Route to fetch list of {key, title, description, image} themes in their respective language
 | //  Route to fetch list of {key, title, description, image} themes in their respective language
 | ||||||
| router.get('/', getThemesHandler); | router.get('/', authenticatedOnly, getThemesHandler); | ||||||
| 
 | 
 | ||||||
| // Arg: theme (key)
 | // Arg: theme (key)
 | ||||||
| //  Route to fetch list of hruids based on theme
 | //  Route to fetch list of hruids based on theme
 | ||||||
| router.get('/:theme', getHruidsByThemeHandler); | router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler); | ||||||
| 
 | 
 | ||||||
| export default router; | export default router; | ||||||
|  |  | ||||||
|  | @ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat | ||||||
|     return mapToAnswerDTO(answer); |     return mapToAnswerDTO(answer); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { | export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { | ||||||
|     const answerRepository = getAnswerRepository(); |     const answerRepository = getAnswerRepository(); | ||||||
|     const question = await fetchQuestion(questionId); |     const question = await fetchQuestion(questionId); | ||||||
|     const answer = await answerRepository.findAnswer(question, sequenceNumber); |     const answer = await answerRepository.findAnswer(question, sequenceNumber); | ||||||
|  |  | ||||||
|  | @ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou | ||||||
|     return group; |     return group; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise<Group[]> { | ||||||
|  |     const assignment = await fetchAssignment(classId, assignmentNumber); | ||||||
|  | 
 | ||||||
|  |     const groupRepository = getGroupRepository(); | ||||||
|  |     const groups = await groupRepository.findAllGroupsForAssignment(assignment); | ||||||
|  | 
 | ||||||
|  |     return groups; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { | export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { | ||||||
|     const group = await fetchGroup(classId, assignmentNumber, groupNumber); |     const group = await fetchGroup(classId, assignmentNumber, groupNumber); | ||||||
|     return mapToGroupDTO(group, group.assignment.within); |     return mapToGroupDTO(group, group.assignment.within); | ||||||
|  |  | ||||||
|  | @ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = { | ||||||
|         ); |         ); | ||||||
|         return learningObjects.filter((it) => it !== null); |         return learningObjects.filter((it) => it !== null); | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Returns all learning objects containing the given username as an admin. | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> { | ||||||
|  |         const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  |         const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername); | ||||||
|  |         return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null); | ||||||
|  |     }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default databaseLearningObjectProvider; | export default databaseLearningObjectProvider; | ||||||
|  |  | ||||||
|  | @ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { | ||||||
| 
 | 
 | ||||||
|         return html; |         return html; | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain all learning objects who have the user with the given username as an admin. | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectsAdministratedBy(_adminUsername: string): Promise<FilteredLearningObject[]> { | ||||||
|  |         return []; // The dwengo database does not contain any learning objects administrated by users.
 | ||||||
|  |     }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default dwengoApiLearningObjectProvider; | export default dwengoApiLearningObjectProvider; | ||||||
|  |  | ||||||
|  | @ -20,4 +20,9 @@ export interface LearningObjectProvider { | ||||||
|      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). |      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||||
|      */ |      */ | ||||||
|     getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>; |     getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain all learning object who have the user with the given username as an admin. | ||||||
|  |      */ | ||||||
|  |     getLearningObjectsAdministratedBy(username: string): Promise<FilteredLearningObject[]>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js'; | ||||||
| import { envVars, getEnvVar } from '../../util/envVars.js'; | import { envVars, getEnvVar } from '../../util/envVars.js'; | ||||||
| import databaseLearningObjectProvider from './database-learning-object-provider.js'; | import databaseLearningObjectProvider from './database-learning-object-provider.js'; | ||||||
| import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
|  | import { getLearningObjectRepository, getTeacherRepository } from '../../data/repositories.js'; | ||||||
|  | import { processLearningObjectZip } from './learning-object-zip-processing-service.js'; | ||||||
|  | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
|  | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||||
|  | import { NotFoundException } from '../../exceptions/not-found-exception.js'; | ||||||
| 
 | 
 | ||||||
| function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { | function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { | ||||||
|     if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { |     if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { | ||||||
|  | @ -42,6 +47,66 @@ const learningObjectService = { | ||||||
|     async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> { |     async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> { | ||||||
|         return getProvider(id).getLearningObjectHTML(id); |         return getProvider(id).getLearningObjectHTML(id); | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain all learning objects administrated by the user with the given username. | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> { | ||||||
|  |         return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Store the learning object in the given zip file in the database. | ||||||
|  |      * @param learningObjectPath The path where the uploaded learning object resides. | ||||||
|  |      * @param admins The usernames of the users which should be administrators of the learning object. | ||||||
|  |      */ | ||||||
|  |     async storeLearningObject(learningObjectPath: string, admins: string[]): Promise<LearningObject> { | ||||||
|  |         const learningObjectRepository = getLearningObjectRepository(); | ||||||
|  |         const learningObject = await processLearningObjectZip(learningObjectPath); | ||||||
|  | 
 | ||||||
|  |         if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { | ||||||
|  |             learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Lookup the admin teachers based on their usernames and add them to the admins of the learning object.
 | ||||||
|  |         const teacherRepo = getTeacherRepository(); | ||||||
|  |         const adminTeachers = await Promise.all(admins.map(async (it) => teacherRepo.findByUsername(it))); | ||||||
|  |         adminTeachers.forEach((it) => { | ||||||
|  |             if (it !== null) { | ||||||
|  |                 learningObject.admins.add(it); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|  |             await learningObjectRepository.save(learningObject, { preventOverwrite: true }); | ||||||
|  |         } catch (e: unknown) { | ||||||
|  |             learningObjectRepository.getEntityManager().clear(); | ||||||
|  |             throw e; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return learningObject; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Deletes the learning object with the given identifier. | ||||||
|  |      */ | ||||||
|  |     async deleteLearningObject(id: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||||
|  |         const learningObjectRepository = getLearningObjectRepository(); | ||||||
|  |         return await learningObjectRepository.removeByIdentifier(id); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Returns a list of the usernames of the administrators of the learning object with the given identifier. | ||||||
|  |      * @throws NotFoundException if the specified learning object was not found in the database. | ||||||
|  |      */ | ||||||
|  |     async getAdmins(id: LearningObjectIdentifier): Promise<string[]> { | ||||||
|  |         const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  |         const learningObject = await learningObjectRepo.findByIdentifier(id); | ||||||
|  |         if (!learningObject) { | ||||||
|  |             throw new NotFoundException('learningObjectNotFound'); | ||||||
|  |         } | ||||||
|  |         return learningObject.admins.map((admin) => admin.username); | ||||||
|  |     }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default learningObjectService; | export default learningObjectService; | ||||||
|  |  | ||||||
|  | @ -0,0 +1,119 @@ | ||||||
|  | import unzipper from 'unzipper'; | ||||||
|  | import mime from 'mime-types'; | ||||||
|  | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
|  | import { getAttachmentRepository, getLearningObjectRepository } from '../../data/repositories.js'; | ||||||
|  | import { BadRequestException } from '../../exceptions/bad-request-exception.js'; | ||||||
|  | import { LearningObjectMetadata } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
|  | import { DwengoContentType } from './processing/content-type.js'; | ||||||
|  | import { v4 } from 'uuid'; | ||||||
|  | 
 | ||||||
|  | const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/; | ||||||
|  | const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Process an uploaded zip file and construct a LearningObject from its contents. | ||||||
|  |  * @param filePath Path of the zip file to process. | ||||||
|  |  */ | ||||||
|  | export async function processLearningObjectZip(filePath: string): Promise<LearningObject> { | ||||||
|  |     let zip: unzipper.CentralDirectory; | ||||||
|  |     try { | ||||||
|  |         zip = await unzipper.Open.file(filePath); | ||||||
|  |     } catch (_: unknown) { | ||||||
|  |         throw new BadRequestException('invalidZip'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let metadata: LearningObjectMetadata | undefined = undefined; | ||||||
|  |     const attachments: { name: string; content: Buffer }[] = []; | ||||||
|  |     let content: Buffer | undefined = undefined; | ||||||
|  | 
 | ||||||
|  |     if (zip.files.length === 0) { | ||||||
|  |         throw new BadRequestException('emptyZip'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await Promise.all( | ||||||
|  |         zip.files.map(async (file) => { | ||||||
|  |             if (file.type !== 'Directory') { | ||||||
|  |                 if (METADATA_PATH_REGEX.test(file.path)) { | ||||||
|  |                     metadata = await processMetadataJson(file); | ||||||
|  |                 } else if (CONTENT_PATH_REGEX.test(file.path)) { | ||||||
|  |                     content = await processFile(file); | ||||||
|  |                 } else { | ||||||
|  |                     attachments.push({ | ||||||
|  |                         name: file.path, | ||||||
|  |                         content: await processFile(file), | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     if (!metadata) { | ||||||
|  |         throw new BadRequestException('missingMetadata'); | ||||||
|  |     } | ||||||
|  |     if (!content) { | ||||||
|  |         throw new BadRequestException('missingIndex'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const learningObject = createLearningObject(metadata, content, attachments); | ||||||
|  | 
 | ||||||
|  |     return learningObject; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function createLearningObject(metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer }[]): LearningObject { | ||||||
|  |     const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  |     const attachmentRepo = getAttachmentRepository(); | ||||||
|  | 
 | ||||||
|  |     const returnValue = { | ||||||
|  |         callbackUrl: metadata.return_value?.callback_url ?? '', | ||||||
|  |         callbackSchema: metadata.return_value?.callback_schema ? JSON.stringify(metadata.return_value.callback_schema) : '', | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     if (!metadata.target_ages || metadata.target_ages.length === 0) { | ||||||
|  |         throw new BadRequestException('targetAgesMandatory'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const learningObject = learningObjectRepo.create({ | ||||||
|  |         admins: [], | ||||||
|  |         available: metadata.available ?? true, | ||||||
|  |         content: content, | ||||||
|  |         contentType: metadata.content_type as DwengoContentType, | ||||||
|  |         copyright: metadata.copyright ?? '', | ||||||
|  |         description: metadata.description ?? '', | ||||||
|  |         educationalGoals: metadata.educational_goals ?? [], | ||||||
|  |         hruid: metadata.hruid, | ||||||
|  |         keywords: metadata.keywords, | ||||||
|  |         language: metadata.language, | ||||||
|  |         license: metadata.license ?? '', | ||||||
|  |         returnValue, | ||||||
|  |         skosConcepts: metadata.skos_concepts ?? [], | ||||||
|  |         teacherExclusive: metadata.teacher_exclusive, | ||||||
|  |         title: metadata.title, | ||||||
|  |         version: metadata.version, | ||||||
|  |         estimatedTime: metadata.estimated_time ?? 1, | ||||||
|  |         targetAges: metadata.target_ages ?? [], | ||||||
|  |         difficulty: metadata.difficulty ?? 1, | ||||||
|  |         uuid: v4(), | ||||||
|  |     }); | ||||||
|  |     const attachmentEntities = attachments.map((it) => | ||||||
|  |         attachmentRepo.create({ | ||||||
|  |             name: it.name, | ||||||
|  |             content: it.content, | ||||||
|  |             mimeType: mime.lookup(it.name) || 'text/plain', | ||||||
|  |             learningObject, | ||||||
|  |         }) | ||||||
|  |     ); | ||||||
|  |     attachmentEntities.forEach((it) => { | ||||||
|  |         learningObject.attachments.add(it); | ||||||
|  |     }); | ||||||
|  |     return learningObject; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function processMetadataJson(file: unzipper.File): Promise<LearningObjectMetadata> { | ||||||
|  |     const buf = await file.buffer(); | ||||||
|  |     const content = buf.toString(); | ||||||
|  |     return JSON.parse(content); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function processFile(file: unzipper.File): Promise<Buffer> { | ||||||
|  |     return await file.buffer(); | ||||||
|  | } | ||||||
|  | @ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js'; | ||||||
| import learningObjectService from '../learning-objects/learning-object-service.js'; | import learningObjectService from '../learning-objects/learning-object-service.js'; | ||||||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||||
| import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; | import { getLastSubmissionForGroup, idFromLearningPathNode, isTransitionPossible } from './learning-path-personalization-util.js'; | ||||||
| import { | import { | ||||||
|     FilteredLearningObject, |     FilteredLearningObject, | ||||||
|     LearningObjectNode, |     LearningObjectNode, | ||||||
|  | @ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language'; | ||||||
| import { Group } from '../../entities/assignments/group.entity'; | import { Group } from '../../entities/assignments/group.entity'; | ||||||
| import { Collection } from '@mikro-orm/core'; | import { Collection } from '@mikro-orm/core'; | ||||||
| import { v4 } from 'uuid'; | import { v4 } from 'uuid'; | ||||||
|  | import { getLogger } from '../../logging/initalize.js'; | ||||||
|  | 
 | ||||||
|  | const logger = getLogger(); | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its |  * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its | ||||||
|  | @ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>): | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     ); |     ); | ||||||
|     if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) { | 
 | ||||||
|         throw new Error('At least one of the learning objects on this path could not be found.'); |     // Ignore all learning objects that cannot be found such that the rest of the learning path keeps working.
 | ||||||
|  |     for (const [key, value] of nullableNodesToLearningObjects) { | ||||||
|  |         if (value === null) { | ||||||
|  |             logger.warn(`Learning object ${key.learningObjectHruid}/${key.language}/${key.version} not found!`); | ||||||
|  |             nullableNodesToLearningObjects.delete(key); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|     return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>; |     return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>; | ||||||
| } | } | ||||||
|  | @ -62,6 +70,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | ||||||
|     // Convert the learning object notes as retrieved from the database into the expected response format-
 |     // Convert the learning object notes as retrieved from the database into the expected response format-
 | ||||||
|     const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); |     const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); | ||||||
| 
 | 
 | ||||||
|  |     const nodesActuallyOnPath = traverseLearningPath(convertedNodes); | ||||||
|  | 
 | ||||||
|     return { |     return { | ||||||
|         _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
 |         _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
 | ||||||
|         __order: order, |         __order: order, | ||||||
|  | @ -71,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | ||||||
|         image: image, |         image: image, | ||||||
|         title: learningPath.title, |         title: learningPath.title, | ||||||
|         nodes: convertedNodes, |         nodes: convertedNodes, | ||||||
|         num_nodes: learningPath.nodes.length, |         num_nodes: nodesActuallyOnPath.length, | ||||||
|         num_nodes_left: convertedNodes.filter((it) => !it.done).length, |         num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length, | ||||||
|         keywords: keywords.join(' '), |         keywords: keywords.join(' '), | ||||||
|         target_ages: targetAges, |         target_ages: targetAges, | ||||||
|         max_age: Math.max(...targetAges), |         max_age: Math.max(...targetAges), | ||||||
|  | @ -95,14 +105,22 @@ async function convertNode( | ||||||
|     personalizedFor: Group | undefined, |     personalizedFor: Group | undefined, | ||||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> |     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> | ||||||
| ): Promise<LearningObjectNode> { | ): Promise<LearningObjectNode> { | ||||||
|     const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; |     const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningPathNode(node), personalizedFor) : null; | ||||||
|     const transitions = node.transitions |     const transitions = node.transitions | ||||||
|         .filter( |         .filter( | ||||||
|             (trans) => |             (trans) => | ||||||
|                 !personalizedFor || // If we do not want a personalized learning path, keep all transitions
 |                 !personalizedFor || // If we do not want a personalized learning path, keep all transitions
 | ||||||
|                 isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
 |                 isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
 | ||||||
|         ) |         ) | ||||||
|         .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)); |         .map((trans, i) => { | ||||||
|  |             try { | ||||||
|  |                 return convertTransition(trans, i, nodesToLearningObjects); | ||||||
|  |             } catch (_: unknown) { | ||||||
|  |                 logger.error(`Transition could not be resolved: ${JSON.stringify(trans)}`); | ||||||
|  |                 return undefined; // Do not crash on invalid transitions, just ignore them so the rest of the learning path keeps working.
 | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |         .filter((it) => it !== undefined); | ||||||
|     return { |     return { | ||||||
|         _id: learningObject.uuid, |         _id: learningObject.uuid, | ||||||
|         language: learningObject.language, |         language: learningObject.language, | ||||||
|  | @ -174,6 +192,29 @@ function convertTransition( | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Start from the start node and then always take the first transition until there are no transitions anymore. | ||||||
|  |  * Returns the traversed nodes as an array. (This effectively filters outs nodes that cannot be reached.) | ||||||
|  |  */ | ||||||
|  | function traverseLearningPath(nodes: LearningObjectNode[]): LearningObjectNode[] { | ||||||
|  |     const traversedNodes: LearningObjectNode[] = []; | ||||||
|  |     let currentNode = nodes.find((it) => it.start_node); | ||||||
|  | 
 | ||||||
|  |     while (currentNode) { | ||||||
|  |         traversedNodes.push(currentNode); | ||||||
|  | 
 | ||||||
|  |         const next = currentNode.transitions[0]?.next; | ||||||
|  | 
 | ||||||
|  |         if (next) { | ||||||
|  |             currentNode = nodes.find((it) => it.learningobject_hruid === next.hruid && it.language === next.language && it.version === next.version); | ||||||
|  |         } else { | ||||||
|  |             currentNode = undefined; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return traversedNodes; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Service providing access to data about learning paths from the database. |  * Service providing access to data about learning paths from the database. | ||||||
|  */ |  */ | ||||||
|  | @ -198,6 +239,15 @@ const databaseLearningPathProvider: LearningPathProvider = { | ||||||
|         }; |         }; | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Returns all the learning paths which have the user with the given username as an administrator. | ||||||
|  |      */ | ||||||
|  |     async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> { | ||||||
|  |         const repo = getLearningPathRepository(); | ||||||
|  |         const paths = await repo.findAllByAdminUsername(adminUsername); | ||||||
|  |         return await Promise.all(paths.map(async (result, index) => convertLearningPath(result, index))); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Search learning paths in the database using the given search string. |      * Search learning paths in the database using the given search string. | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|  | @ -3,11 +3,33 @@ import { DWENGO_API_BASE } from '../../config.js'; | ||||||
| import { LearningPathProvider } from './learning-path-provider.js'; | import { LearningPathProvider } from './learning-path-provider.js'; | ||||||
| import { getLogger, Logger } from '../../logging/initalize.js'; | import { getLogger, Logger } from '../../logging/initalize.js'; | ||||||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
|  | import { Group } from '../../entities/assignments/group.entity.js'; | ||||||
|  | import { getLastSubmissionForGroup, idFromLearningObjectNode } from './learning-path-personalization-util.js'; | ||||||
| 
 | 
 | ||||||
| const logger: Logger = getLogger(); | const logger: Logger = getLogger(); | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Adds progress information to the learning path. Modifies the learning path in-place. | ||||||
|  |  * @param learningPath The learning path to add progress to. | ||||||
|  |  * @param personalizedFor The group whose progress should be shown. | ||||||
|  |  * @returns the modified learning path. | ||||||
|  |  */ | ||||||
|  | async function addProgressToLearningPath(learningPath: LearningPath, personalizedFor: Group): Promise<LearningPath> { | ||||||
|  |     await Promise.all( | ||||||
|  |         learningPath.nodes.map(async (node) => { | ||||||
|  |             const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningObjectNode(node), personalizedFor) : null; | ||||||
|  |             node.done = Boolean(lastSubmission); | ||||||
|  |         }) | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     learningPath.num_nodes = learningPath.nodes.length; | ||||||
|  |     learningPath.num_nodes_left = learningPath.nodes.filter((it) => !it.done).length; | ||||||
|  | 
 | ||||||
|  |     return learningPath; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| const dwengoApiLearningPathProvider: LearningPathProvider = { | const dwengoApiLearningPathProvider: LearningPathProvider = { | ||||||
|     async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> { |     async fetchLearningPaths(hruids: string[], language: string, source: string, personalizedFor: Group): Promise<LearningPathResponse> { | ||||||
|         if (hruids.length === 0) { |         if (hruids.length === 0) { | ||||||
|             return { |             return { | ||||||
|                 success: false, |                 success: false, | ||||||
|  | @ -32,19 +54,30 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | ||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |         await Promise.all(learningPaths?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); | ||||||
|  | 
 | ||||||
|         return { |         return { | ||||||
|             success: true, |             success: true, | ||||||
|             source, |             source, | ||||||
|             data: learningPaths, |             data: learningPaths, | ||||||
|         }; |         }; | ||||||
|     }, |     }, | ||||||
|     async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { |     async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> { | ||||||
|         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; |         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; | ||||||
|         const params = { all: query, language }; |         const params = { all: query, language }; | ||||||
| 
 | 
 | ||||||
|         const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); |         const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); | ||||||
|  | 
 | ||||||
|  |         if (searchResults) { | ||||||
|  |             await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         return searchResults ?? []; |         return searchResults ?? []; | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     async getLearningPathsAdministratedBy(_adminUsername: string) { | ||||||
|  |         return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
 | ||||||
|  |     }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default dwengoApiLearningPathProvider; | export default dwengoApiLearningPathProvider; | ||||||
|  |  | ||||||
|  | @ -5,18 +5,36 @@ import { getSubmissionRepository } from '../../data/repositories.js'; | ||||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||||
| import { JSONPath } from 'jsonpath-plus'; | import { JSONPath } from 'jsonpath-plus'; | ||||||
|  | import { LearningObjectNode } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Returns the last submission for the learning object associated with the given node and for the group |  * Returns the last submission for the learning object associated with the given node and for the group | ||||||
|  */ |  */ | ||||||
| export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> { | export async function getLastSubmissionForGroup(learningObjectId: LearningObjectIdentifier, pathFor: Group): Promise<Submission | null> { | ||||||
|     const submissionRepo = getSubmissionRepository(); |     const submissionRepo = getSubmissionRepository(); | ||||||
|     const learningObjectId: LearningObjectIdentifier = { |     return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Creates a LearningObjectIdentifier describing the specified node. | ||||||
|  |  */ | ||||||
|  | export function idFromLearningObjectNode(node: LearningObjectNode): LearningObjectIdentifier { | ||||||
|  |     return { | ||||||
|  |         hruid: node.learningobject_hruid, | ||||||
|  |         language: node.language, | ||||||
|  |         version: node.version, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Creates a LearningObjectIdentifier describing the specified node. | ||||||
|  |  */ | ||||||
|  | export function idFromLearningPathNode(node: LearningPathNode): LearningObjectIdentifier { | ||||||
|  |     return { | ||||||
|         hruid: node.learningObjectHruid, |         hruid: node.learningObjectHruid, | ||||||
|         language: node.language, |         language: node.language, | ||||||
|         version: node.version, |         version: node.version, | ||||||
|     }; |     }; | ||||||
|     return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  |  | ||||||
|  | @ -15,4 +15,9 @@ export interface LearningPathProvider { | ||||||
|      * Search learning paths in the data source using the given search string. |      * Search learning paths in the data source using the given search string. | ||||||
|      */ |      */ | ||||||
|     searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; |     searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Get all learning paths which have the teacher with the given user as an administrator. | ||||||
|  |      */ | ||||||
|  |     getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; | import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; | ||||||
| import databaseLearningPathProvider from './database-learning-path-provider.js'; | import databaseLearningPathProvider from './database-learning-path-provider.js'; | ||||||
| import { envVars, getEnvVar } from '../../util/envVars.js'; | import { envVars, getEnvVar } from '../../util/envVars.js'; | ||||||
| import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | import { Language } from '@dwengo-1/common/util/language'; | ||||||
| import { Group } from '../../entities/assignments/group.entity.js'; | import { Group } from '../../entities/assignments/group.entity.js'; | ||||||
| import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; | import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; | ||||||
|  | @ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; | ||||||
| import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; | import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; | ||||||
| import { mapToTeacher } from '../../interfaces/teacher.js'; | import { mapToTeacher } from '../../interfaces/teacher.js'; | ||||||
| import { Collection } from '@mikro-orm/core'; | import { Collection } from '@mikro-orm/core'; | ||||||
|  | import { NotFoundException } from '../../exceptions/not-found-exception.js'; | ||||||
|  | import { BadRequestException } from '../../exceptions/bad-request-exception.js'; | ||||||
|  | import learningObjectService from '../learning-objects/learning-object-service.js'; | ||||||
| 
 | 
 | ||||||
| const userContentPrefix = getEnvVar(envVars.UserContentPrefix); | const userContentPrefix = getEnvVar(envVars.UserContentPrefix); | ||||||
| const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | ||||||
|  | @ -43,13 +46,10 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | ||||||
|         const fromNode = nodes.find( |         const fromNode = nodes.find( | ||||||
|             (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version |             (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version | ||||||
|         )!; |         )!; | ||||||
|         const transitions = nodeDto.transitions |         const transitions = nodeDto.transitions.map((transDto, i) => { | ||||||
|             .map((transDto, i) => { |  | ||||||
|             const toNode = nodes.find( |             const toNode = nodes.find( | ||||||
|                 (it) => |                 (it) => | ||||||
|                         it.learningObjectHruid === transDto.next.hruid && |                     it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version | ||||||
|                         it.language === transDto.next.language && |  | ||||||
|                         it.version === transDto.next.version |  | ||||||
|             ); |             ); | ||||||
| 
 | 
 | ||||||
|             if (toNode) { |             if (toNode) { | ||||||
|  | @ -60,10 +60,10 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | ||||||
|                     condition: transDto.condition ?? 'true', |                     condition: transDto.condition ?? 'true', | ||||||
|                 }); |                 }); | ||||||
|             } |             } | ||||||
|                 return undefined; |             throw new BadRequestException( | ||||||
|             }) |                 `Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.` | ||||||
|             .filter((it) => it) |             ); | ||||||
|             .map((it) => it!); |         }); | ||||||
| 
 | 
 | ||||||
|         fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions); |         fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions); | ||||||
|     }); |     }); | ||||||
|  | @ -105,6 +105,14 @@ const learningPathService = { | ||||||
|         }; |         }; | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch the learning paths administrated by the teacher with the given username. | ||||||
|  |      */ | ||||||
|  |     async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> { | ||||||
|  |         const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername))); | ||||||
|  |         return providerResponses.flat(); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * Search learning paths in the data source using the given search string. |      * Search learning paths in the data source using the given search string. | ||||||
|      */ |      */ | ||||||
|  | @ -119,11 +127,67 @@ const learningPathService = { | ||||||
|      * Add a new learning path to the database. |      * Add a new learning path to the database. | ||||||
|      * @param dto Learning path DTO from which the learning path will be created. |      * @param dto Learning path DTO from which the learning path will be created. | ||||||
|      * @param admins Teachers who should become an admin of the learning path. |      * @param admins Teachers who should become an admin of the learning path. | ||||||
|  |      * @returns the created learning path. | ||||||
|      */ |      */ | ||||||
|     async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> { |     async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<LearningPathEntity> { | ||||||
|         const repo = getLearningPathRepository(); |         const repo = getLearningPathRepository(); | ||||||
|  | 
 | ||||||
|  |         const userContentPrefix = getEnvVar(envVars.UserContentPrefix); | ||||||
|  |         if (!dto.hruid.startsWith(userContentPrefix)) { | ||||||
|  |             dto.hruid = userContentPrefix + dto.hruid; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         const path = mapToLearningPath(dto, admins); |         const path = mapToLearningPath(dto, admins); | ||||||
|  | 
 | ||||||
|  |         // Verify that all specified learning objects actually exist.
 | ||||||
|  |         const learningObjectsOnPath = await Promise.all( | ||||||
|  |             path.nodes.map(async (node) => | ||||||
|  |                 learningObjectService.getLearningObjectById({ | ||||||
|  |                     hruid: node.learningObjectHruid, | ||||||
|  |                     language: node.language, | ||||||
|  |                     version: node.version, | ||||||
|  |                 }) | ||||||
|  |             ) | ||||||
|  |         ); | ||||||
|  |         if (learningObjectsOnPath.some((it) => !it)) { | ||||||
|  |             throw new BadRequestException('pathContainsNonExistingLearningObjects'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|             await repo.save(path, { preventOverwrite: true }); |             await repo.save(path, { preventOverwrite: true }); | ||||||
|  |         } catch (e: unknown) { | ||||||
|  |             repo.getEntityManager().clear(); | ||||||
|  |             throw e; | ||||||
|  |         } | ||||||
|  |         return path; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Deletes the learning path with the given identifier from the database. | ||||||
|  |      * @param id Identifier of the learning path to delete. | ||||||
|  |      * @returns the deleted learning path. | ||||||
|  |      */ | ||||||
|  |     async deleteLearningPath(id: LearningPathIdentifier): Promise<LearningPathEntity> { | ||||||
|  |         const repo = getLearningPathRepository(); | ||||||
|  | 
 | ||||||
|  |         const deletedPath = await repo.deleteByHruidAndLanguage(id.hruid, id.language); | ||||||
|  |         if (deletedPath) { | ||||||
|  |             return deletedPath; | ||||||
|  |         } | ||||||
|  |         throw new NotFoundException('No learning path with the given identifier found.'); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Returns a list of the usernames of the administrators of the learning path with the given identifier. | ||||||
|  |      * @param id The identifier of the learning path whose admins should be fetched. | ||||||
|  |      */ | ||||||
|  |     async getAdmins(id: LearningPathIdentifier): Promise<string[]> { | ||||||
|  |         const repo = getLearningPathRepository(); | ||||||
|  |         const path = await repo.findByHruidAndLanguage(id.hruid, id.language); | ||||||
|  |         if (!path) { | ||||||
|  |             throw new NotFoundException('No learning path with the given identifier found.'); | ||||||
|  |         } | ||||||
|  |         return path.admins.map((admin) => admin.username); | ||||||
|     }, |     }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ import { fetchStudent } from './students.js'; | ||||||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||||
| import { FALLBACK_VERSION_NUM } from '../config.js'; | import { FALLBACK_VERSION_NUM } from '../config.js'; | ||||||
| import { fetchAssignment } from './assignments.js'; | import { fetchAssignment } from './assignments.js'; | ||||||
|  | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
| 
 | 
 | ||||||
| export async function getQuestionsAboutLearningObjectInAssignment( | export async function getQuestionsAboutLearningObjectInAssignment( | ||||||
|     loId: LearningObjectIdentifier, |     loId: LearningObjectIdentifier, | ||||||
|  | @ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat | ||||||
| 
 | 
 | ||||||
|     const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); |     const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); | ||||||
| 
 | 
 | ||||||
|  |     if (!inGroup) { | ||||||
|  |         throw new NotFoundException('Group with id and assignment not found'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (!inGroup.members.contains(author)) { | ||||||
|  |         throw new ConflictException('Author is not part of this group'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const question = await questionRepository.createQuestion({ |     const question = await questionRepository.createQuestion({ | ||||||
|         loId, |         loId, | ||||||
|         author, |         author, | ||||||
|         inGroup: inGroup!, |         inGroup: inGroup, | ||||||
|         content, |         content, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm | ||||||
| import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; | import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; | ||||||
| import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | ||||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
| import { Submission } from '../entities/assignments/submission.entity'; | import { Submission } from '../entities/assignments/submission.entity.js'; | ||||||
|  | import { mapToUsername } from '../interfaces/user.js'; | ||||||
| 
 | 
 | ||||||
| export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { | export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { | ||||||
|     const studentRepository = getStudentRepository(); |     const studentRepository = getStudentRepository(); | ||||||
|  | @ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise<StudentDTO[] | stri | ||||||
|         return users.map(mapToStudentDTO); |         return users.map(mapToStudentDTO); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return users.map((user) => user.username); |     return users.map(mapToUsername); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function fetchStudent(username: string): Promise<Student> { | export async function fetchStudent(username: string): Promise<Student> { | ||||||
|  | @ -42,7 +43,7 @@ export async function fetchStudent(username: string): Promise<Student> { | ||||||
|     const user = await studentRepository.findByUsername(username); |     const user = await studentRepository.findByUsername(username); | ||||||
| 
 | 
 | ||||||
|     if (!user) { |     if (!user) { | ||||||
|         throw new NotFoundException('Student with username not found'); |         throw new NotFoundException(`Student with username ${username} not found`); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return user; |     return user; | ||||||
|  | @ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> { | ||||||
|     const newStudent = mapToStudent(userData); |     const newStudent = mapToStudent(userData); | ||||||
|     await studentRepository.save(newStudent, { preventOverwrite: true }); |     await studentRepository.save(newStudent, { preventOverwrite: true }); | ||||||
| 
 | 
 | ||||||
|     return userData; |     return mapToStudentDTO(newStudent); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { | export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { | ||||||
|  |  | ||||||
|  | @ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise<Tea | ||||||
|         throw new ConflictException('The teacher sending the invite is not part of the class'); |         throw new ConflictException('The teacher sending the invite is not part of the class'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (cls.teachers.contains(receiver)) { | ||||||
|  |         throw new ConflictException('The teacher receiving the invite is already part of the class'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const newInvitation = mapToInvitation(sender, receiver, cls); |     const newInvitation = mapToInvitation(sender, receiver, cls); | ||||||
|     await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); |     await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,12 +2,9 @@ import { | ||||||
|     getAssignmentRepository, |     getAssignmentRepository, | ||||||
|     getClassJoinRequestRepository, |     getClassJoinRequestRepository, | ||||||
|     getClassRepository, |     getClassRepository, | ||||||
|     getLearningObjectRepository, |  | ||||||
|     getQuestionRepository, |  | ||||||
|     getTeacherRepository, |     getTeacherRepository, | ||||||
| } from '../data/repositories.js'; | } from '../data/repositories.js'; | ||||||
| import { mapToClassDTO } from '../interfaces/class.js'; | import { mapToClassDTO } from '../interfaces/class.js'; | ||||||
| import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; |  | ||||||
| import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; | import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; | ||||||
| import { Teacher } from '../entities/users/teacher.entity.js'; | import { Teacher } from '../entities/users/teacher.entity.js'; | ||||||
| import { fetchStudent } from './students.js'; | import { fetchStudent } from './students.js'; | ||||||
|  | @ -16,10 +13,6 @@ import { mapToStudentRequestDTO } from '../interfaces/student-request.js'; | ||||||
| import { TeacherRepository } from '../data/users/teacher-repository.js'; | import { TeacherRepository } from '../data/users/teacher-repository.js'; | ||||||
| import { ClassRepository } from '../data/classes/class-repository.js'; | import { ClassRepository } from '../data/classes/class-repository.js'; | ||||||
| import { Class } from '../entities/classes/class.entity.js'; | import { Class } from '../entities/classes/class.entity.js'; | ||||||
| import { LearningObjectRepository } from '../data/content/learning-object-repository.js'; |  | ||||||
| import { LearningObject } from '../entities/content/learning-object.entity.js'; |  | ||||||
| import { QuestionRepository } from '../data/questions/question-repository.js'; |  | ||||||
| import { Question } from '../entities/questions/question.entity.js'; |  | ||||||
| import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js'; | import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js'; | ||||||
| import { Student } from '../entities/users/student.entity.js'; | import { Student } from '../entities/users/student.entity.js'; | ||||||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||||
|  | @ -27,12 +20,12 @@ import { addClassStudent, fetchClass, getClassStudentsDTO } from './classes.js'; | ||||||
| import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; | import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; | ||||||
| import { ClassDTO } from '@dwengo-1/common/interfaces/class'; | import { ClassDTO } from '@dwengo-1/common/interfaces/class'; | ||||||
| import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | ||||||
| import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; |  | ||||||
| import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | ||||||
| import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | ||||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
| import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | ||||||
| import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; | import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; | ||||||
|  | import { mapToUsername } from '../interfaces/user.js'; | ||||||
| 
 | 
 | ||||||
| export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { | export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { | ||||||
|     const teacherRepository: TeacherRepository = getTeacherRepository(); |     const teacherRepository: TeacherRepository = getTeacherRepository(); | ||||||
|  | @ -41,7 +34,7 @@ export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | stri | ||||||
|     if (full) { |     if (full) { | ||||||
|         return users.map(mapToTeacherDTO); |         return users.map(mapToTeacherDTO); | ||||||
|     } |     } | ||||||
|     return users.map((user) => user.username); |     return users.map(mapToUsername); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function fetchTeacher(username: string): Promise<Teacher> { | export async function fetchTeacher(username: string): Promise<Teacher> { | ||||||
|  | @ -60,7 +53,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> { | ||||||
|     return mapToTeacherDTO(user); |     return mapToTeacherDTO(user); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { | // TODO update parameter
 | ||||||
|  | export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise<TeacherDTO> { | ||||||
|     const teacherRepository: TeacherRepository = getTeacherRepository(); |     const teacherRepository: TeacherRepository = getTeacherRepository(); | ||||||
| 
 | 
 | ||||||
|     const newTeacher = mapToTeacher(userData); |     const newTeacher = mapToTeacher(userData); | ||||||
|  | @ -124,7 +118,9 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro | ||||||
| 
 | 
 | ||||||
|     const classIds: string[] = classes.map((cls) => cls.id); |     const classIds: string[] = classes.map((cls) => cls.id); | ||||||
| 
 | 
 | ||||||
|     const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat(); |     const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId)))) | ||||||
|  |         .flat() | ||||||
|  |         .filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index); | ||||||
| 
 | 
 | ||||||
|     if (full) { |     if (full) { | ||||||
|         return students; |         return students; | ||||||
|  | @ -133,28 +129,6 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro | ||||||
|     return students.map((student) => student.username); |     return students.map((student) => student.username); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getTeacherQuestions(username: string, full: boolean): Promise<QuestionDTO[] | QuestionId[]> { |  | ||||||
|     const teacher: Teacher = await fetchTeacher(username); |  | ||||||
| 
 |  | ||||||
|     // Find all learning objects that this teacher manages
 |  | ||||||
|     const learningObjectRepository: LearningObjectRepository = getLearningObjectRepository(); |  | ||||||
|     const learningObjects: LearningObject[] = await learningObjectRepository.findAllByTeacher(teacher); |  | ||||||
| 
 |  | ||||||
|     if (!learningObjects || learningObjects.length === 0) { |  | ||||||
|         return []; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Fetch all questions related to these learning objects
 |  | ||||||
|     const questionRepository: QuestionRepository = getQuestionRepository(); |  | ||||||
|     const questions: Question[] = await questionRepository.findAllByLearningObjects(learningObjects); |  | ||||||
| 
 |  | ||||||
|     if (full) { |  | ||||||
|         return questions.map(mapToQuestionDTO); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return questions.map(mapToQuestionDTOId); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> { | export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> { | ||||||
|     const classRepository: ClassRepository = getClassRepository(); |     const classRepository: ClassRepository = getClassRepository(); | ||||||
|     const cls: Class | null = await classRepository.findById(classId); |     const cls: Class | null = await classRepository.findById(classId); | ||||||
|  |  | ||||||
							
								
								
									
										76
									
								
								backend/tests/controllers/assignments.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								backend/tests/controllers/assignments.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,76 @@ | ||||||
|  | import { setupTestApp } from '../setup-tests.js'; | ||||||
|  | import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { getAssignmentHandler, getAllAssignmentsHandler, getAssignmentsSubmissionsHandler } from '../../src/controllers/assignments.js'; | ||||||
|  | import { NotFoundException } from '../../src/exceptions/not-found-exception'; | ||||||
|  | import { getClass01 } from '../test_assets/classes/classes.testdata'; | ||||||
|  | import { getAssignment01 } from '../test_assets/assignments/assignments.testdata'; | ||||||
|  | 
 | ||||||
|  | function createRequestObject( | ||||||
|  |     classid: string, | ||||||
|  |     assignmentid: string | ||||||
|  | ): { | ||||||
|  |     query: { full: string }; | ||||||
|  |     params: { classid: string; id: string }; | ||||||
|  | } { | ||||||
|  |     return { | ||||||
|  |         params: { | ||||||
|  |             classid: classid, | ||||||
|  |             id: assignmentid, | ||||||
|  |         }, | ||||||
|  |         query: { | ||||||
|  |             full: 'true', | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('Assignment controllers', () => { | ||||||
|  |     let req: Partial<Request>; | ||||||
|  |     let res: Partial<Response>; | ||||||
|  | 
 | ||||||
|  |     let jsonMock: Mock; | ||||||
|  |     let statusMock: Mock; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     beforeEach(async () => { | ||||||
|  |         jsonMock = vi.fn(); | ||||||
|  |         statusMock = vi.fn().mockReturnThis(); | ||||||
|  | 
 | ||||||
|  |         res = { | ||||||
|  |             json: jsonMock, | ||||||
|  |             status: statusMock, | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('return error non-existing assignment', async () => { | ||||||
|  |         req = createRequestObject('doesnotexist', '43000'); // Should not exist
 | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getAssignmentHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return an assignment', async () => { | ||||||
|  |         const assignment = getAssignment01(); | ||||||
|  |         req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString()); | ||||||
|  | 
 | ||||||
|  |         await getAssignmentHandler(req as Request, res as Response); | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignment: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return a list of assignments', async () => { | ||||||
|  |         req = createRequestObject(getClass01().classId as string, 'irrelevant'); | ||||||
|  | 
 | ||||||
|  |         await getAllAssignmentsHandler(req as Request, res as Response); | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return a list of submissions for an assignment', async () => { | ||||||
|  |         const assignment = getAssignment01(); | ||||||
|  |         req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString()); | ||||||
|  | 
 | ||||||
|  |         await getAssignmentsSubmissionsHandler(req as Request, res as Response); | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										123
									
								
								backend/tests/controllers/classes.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								backend/tests/controllers/classes.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,123 @@ | ||||||
|  | import { setupTestApp } from '../setup-tests.js'; | ||||||
|  | import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; | ||||||
|  | import { | ||||||
|  |     createClassHandler, | ||||||
|  |     deleteClassHandler, | ||||||
|  |     getAllClassesHandler, | ||||||
|  |     getClassHandler, | ||||||
|  |     getClassStudentsHandler, | ||||||
|  |     getTeacherInvitationsHandler, | ||||||
|  | } from '../../src/controllers/classes.js'; | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { NotFoundException } from '../../src/exceptions/not-found-exception'; | ||||||
|  | import { BadRequestException } from '../../src/exceptions/bad-request-exception'; | ||||||
|  | import { getClass01 } from '../test_assets/classes/classes.testdata'; | ||||||
|  | describe('Class controllers', () => { | ||||||
|  |     let req: Partial<Request>; | ||||||
|  |     let res: Partial<Response>; | ||||||
|  | 
 | ||||||
|  |     let jsonMock: Mock; | ||||||
|  |     let statusMock: Mock; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     beforeEach(async () => { | ||||||
|  |         jsonMock = vi.fn(); | ||||||
|  |         statusMock = vi.fn().mockReturnThis(); | ||||||
|  | 
 | ||||||
|  |         res = { | ||||||
|  |             json: jsonMock, | ||||||
|  |             status: statusMock, | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('create and delete class', async () => { | ||||||
|  |         req = { | ||||||
|  |             body: { displayName: 'coole_nieuwe_klas' }, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await createClassHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         const result = jsonMock.mock.lastCall?.[0]; | ||||||
|  |         // Console.log('class', result.class);
 | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() })); | ||||||
|  | 
 | ||||||
|  |         req = { | ||||||
|  |             params: { id: result.class.id }, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await deleteClassHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Error class not found', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { id: 'doesnotexist' }, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getClassHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Error create a class without name', async () => { | ||||||
|  |         req = { | ||||||
|  |             body: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => createClassHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('return list of students', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { id: getClass01().classId as string }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await getClassStudentsHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ students: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Error students on a non-existent class', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { id: 'doesnotexist' }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getClassStudentsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return 200 and a list of teacher-invitations', async () => { | ||||||
|  |         const classId = getClass01().classId as string; | ||||||
|  |         req = { | ||||||
|  |             params: { id: classId }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await getTeacherInvitationsHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Error teacher-invitations on a non-existent class', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { id: 'doesnotexist' }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getTeacherInvitationsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return a list of classes', async () => { | ||||||
|  |         req = { | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await getAllClassesHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ classes: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										140
									
								
								backend/tests/controllers/groups.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								backend/tests/controllers/groups.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,140 @@ | ||||||
|  | import { setupTestApp } from '../setup-tests.js'; | ||||||
|  | import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { | ||||||
|  |     createGroupHandler, | ||||||
|  |     deleteGroupHandler, | ||||||
|  |     getAllGroupsHandler, | ||||||
|  |     getGroupHandler, | ||||||
|  |     getGroupSubmissionsHandler, | ||||||
|  | } from '../../src/controllers/groups.js'; | ||||||
|  | import { NotFoundException } from '../../src/exceptions/not-found-exception'; | ||||||
|  | import { getClass01 } from '../test_assets/classes/classes.testdata'; | ||||||
|  | import { getAssignment01, getAssignment02 } from '../test_assets/assignments/assignments.testdata'; | ||||||
|  | import { getTestGroup01 } from '../test_assets/assignments/groups.testdata'; | ||||||
|  | 
 | ||||||
|  | function createRequestObject( | ||||||
|  |     classid: string, | ||||||
|  |     assignmentid: string, | ||||||
|  |     groupNumber: string | ||||||
|  | ): { | ||||||
|  |     query: { full: string }; | ||||||
|  |     params: { classid: string; groupid: string; assignmentid: string }; | ||||||
|  | } { | ||||||
|  |     return { | ||||||
|  |         params: { | ||||||
|  |             classid: classid, | ||||||
|  |             assignmentid: assignmentid, | ||||||
|  |             groupid: groupNumber, | ||||||
|  |         }, | ||||||
|  |         query: { | ||||||
|  |             full: 'true', | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('Group controllers', () => { | ||||||
|  |     let req: Partial<Request>; | ||||||
|  |     let res: Partial<Response>; | ||||||
|  | 
 | ||||||
|  |     let jsonMock: Mock; | ||||||
|  |     let statusMock: Mock; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     beforeEach(async () => { | ||||||
|  |         jsonMock = vi.fn(); | ||||||
|  |         statusMock = vi.fn().mockReturnThis(); | ||||||
|  | 
 | ||||||
|  |         res = { | ||||||
|  |             json: jsonMock, | ||||||
|  |             status: statusMock, | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Error not found on a non-existing group', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { | ||||||
|  |                 classid: 'id01', | ||||||
|  |                 assignmentid: '1', | ||||||
|  |                 groupid: '154981', // Should not exist
 | ||||||
|  |             }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return 404 not found on a non-existing assignment', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { | ||||||
|  |                 classid: 'id01', | ||||||
|  |                 assignmentid: '1000', // Should not exist
 | ||||||
|  |                 groupid: '42000', // Should not exist
 | ||||||
|  |             }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return 404 not found ont a non-existing class', async () => { | ||||||
|  |         req = { | ||||||
|  |             params: { | ||||||
|  |                 classid: 'doesnotexist', // Should not exist
 | ||||||
|  |                 assignmentid: '1000', // Should not exist
 | ||||||
|  |                 groupid: '42000', // Should not exist
 | ||||||
|  |             }, | ||||||
|  |             query: {}, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return an existing group', async () => { | ||||||
|  |         const group = getTestGroup01(); | ||||||
|  |         const classId = getClass01().classId as string; | ||||||
|  |         req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString()); | ||||||
|  | 
 | ||||||
|  |         await getGroupHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('Create and delete', async () => { | ||||||
|  |         const assignment = getAssignment02(); | ||||||
|  |         const classId = assignment.within.classId as string; | ||||||
|  |         req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1'); | ||||||
|  |         req.body = { | ||||||
|  |             members: ['Noordkaap', 'DireStraits'], | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         await createGroupHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         await deleteGroupHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the submissions for a group', async () => { | ||||||
|  |         const group = getTestGroup01(); | ||||||
|  |         const classId = getClass01().classId as string; | ||||||
|  |         req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString()); | ||||||
|  | 
 | ||||||
|  |         await getGroupSubmissionsHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return a list of groups for an assignment', async () => { | ||||||
|  |         const assignment = getAssignment01(); | ||||||
|  |         const classId = assignment.within.classId as string; | ||||||
|  |         req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1'); | ||||||
|  | 
 | ||||||
|  |         await getAllGroupsHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ groups: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -21,6 +21,7 @@ import { BadRequestException } from '../../src/exceptions/bad-request-exception. | ||||||
| import { ConflictException } from '../../src/exceptions/conflict-exception.js'; | import { ConflictException } from '../../src/exceptions/conflict-exception.js'; | ||||||
| import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; | import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; | ||||||
| import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | ||||||
|  | import { getClass02 } from '../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('Student controllers', () => { | describe('Student controllers', () => { | ||||||
|     let req: Partial<Request>; |     let req: Partial<Request>; | ||||||
|  | @ -186,7 +187,7 @@ describe('Student controllers', () => { | ||||||
| 
 | 
 | ||||||
|     it('Get join request by student and class', async () => { |     it('Get join request by student and class', async () => { | ||||||
|         req = { |         req = { | ||||||
|             params: { username: 'PinkFloyd', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             params: { username: 'PinkFloyd', classId: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await getStudentRequestHandler(req as Request, res as Response); |         await getStudentRequestHandler(req as Request, res as Response); | ||||||
|  | @ -201,7 +202,7 @@ describe('Student controllers', () => { | ||||||
|     it('Create and delete join request', async () => { |     it('Create and delete join request', async () => { | ||||||
|         req = { |         req = { | ||||||
|             params: { username: 'TheDoors' }, |             params: { username: 'TheDoors' }, | ||||||
|             body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             body: { classId: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await createStudentRequestHandler(req as Request, res as Response); |         await createStudentRequestHandler(req as Request, res as Response); | ||||||
|  | @ -209,7 +210,7 @@ describe('Student controllers', () => { | ||||||
|         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() })); |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() })); | ||||||
| 
 | 
 | ||||||
|         req = { |         req = { | ||||||
|             params: { username: 'TheDoors', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             params: { username: 'TheDoors', classId: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await deleteClassJoinRequestHandler(req as Request, res as Response); |         await deleteClassJoinRequestHandler(req as Request, res as Response); | ||||||
|  | @ -222,7 +223,7 @@ describe('Student controllers', () => { | ||||||
|     it('Create join request student already in class error', async () => { |     it('Create join request student already in class error', async () => { | ||||||
|         req = { |         req = { | ||||||
|             params: { username: 'Noordkaap' }, |             params: { username: 'Noordkaap' }, | ||||||
|             body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             body: { classId: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); |         await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); | ||||||
|  | @ -231,7 +232,7 @@ describe('Student controllers', () => { | ||||||
|     it('Create join request duplicate', async () => { |     it('Create join request duplicate', async () => { | ||||||
|         req = { |         req = { | ||||||
|             params: { username: 'Tool' }, |             params: { username: 'Tool' }, | ||||||
|             body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             body: { classId: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); |         await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); | ||||||
|  |  | ||||||
							
								
								
									
										61
									
								
								backend/tests/controllers/submissions.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/tests/controllers/submissions.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | ||||||
|  | import { setupTestApp } from '../setup-tests.js'; | ||||||
|  | import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; | ||||||
|  | import { getSubmissionHandler, getAllSubmissionsHandler } from '../../src/controllers/submissions.js'; | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { NotFoundException } from '../../src/exceptions/not-found-exception'; | ||||||
|  | import { getClass02 } from '../test_assets/classes/classes.testdata'; | ||||||
|  | 
 | ||||||
|  | function createRequestObject( | ||||||
|  |     hruid: string, | ||||||
|  |     submissionNumber: string | ||||||
|  | ): { | ||||||
|  |     query: { language: string; version: string }; | ||||||
|  |     params: { hruid: string; id: string }; | ||||||
|  | } { | ||||||
|  |     return { | ||||||
|  |         params: { | ||||||
|  |             hruid: hruid, | ||||||
|  |             id: submissionNumber, | ||||||
|  |         }, | ||||||
|  |         query: { | ||||||
|  |             language: 'en', | ||||||
|  |             version: '1', | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('Submission controllers', () => { | ||||||
|  |     let req: Partial<Request>; | ||||||
|  |     let res: Partial<Response>; | ||||||
|  | 
 | ||||||
|  |     let jsonMock: Mock; | ||||||
|  |     let statusMock: Mock; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     beforeEach(async () => { | ||||||
|  |         jsonMock = vi.fn(); | ||||||
|  |         statusMock = vi.fn().mockReturnThis(); | ||||||
|  | 
 | ||||||
|  |         res = { | ||||||
|  |             json: jsonMock, | ||||||
|  |             status: statusMock, | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('error submission is not found', async () => { | ||||||
|  |         req = createRequestObject('id01', '1000000'); | ||||||
|  | 
 | ||||||
|  |         await expect(async () => getSubmissionHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return a list of submissions for a learning object', async () => { | ||||||
|  |         req = createRequestObject(getClass02().classId as string, 'irrelevant'); | ||||||
|  | 
 | ||||||
|  |         await getAllSubmissionsHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() })); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -12,6 +12,7 @@ import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invit | ||||||
| import { getClassHandler } from '../../src/controllers/classes'; | import { getClassHandler } from '../../src/controllers/classes'; | ||||||
| import { BadRequestException } from '../../src/exceptions/bad-request-exception'; | import { BadRequestException } from '../../src/exceptions/bad-request-exception'; | ||||||
| import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | ||||||
|  | import { getClass02 } from '../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('Teacher controllers', () => { | describe('Teacher controllers', () => { | ||||||
|     let req: Partial<Request>; |     let req: Partial<Request>; | ||||||
|  | @ -57,7 +58,7 @@ describe('Teacher controllers', () => { | ||||||
|         const body = { |         const body = { | ||||||
|             sender: 'LimpBizkit', |             sender: 'LimpBizkit', | ||||||
|             receiver: 'testleerkracht1', |             receiver: 'testleerkracht1', | ||||||
|             class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', |             class: getClass02().classId, | ||||||
|         } as TeacherInvitationData; |         } as TeacherInvitationData; | ||||||
|         req = { body }; |         req = { body }; | ||||||
| 
 | 
 | ||||||
|  | @ -67,7 +68,7 @@ describe('Teacher controllers', () => { | ||||||
|             params: { |             params: { | ||||||
|                 sender: 'LimpBizkit', |                 sender: 'LimpBizkit', | ||||||
|                 receiver: 'testleerkracht1', |                 receiver: 'testleerkracht1', | ||||||
|                 classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', |                 classId: getClass02().classId, | ||||||
|             }, |             }, | ||||||
|             body: { accepted: 'false' }, |             body: { accepted: 'false' }, | ||||||
|         }; |         }; | ||||||
|  | @ -80,7 +81,7 @@ describe('Teacher controllers', () => { | ||||||
|             params: { |             params: { | ||||||
|                 sender: 'LimpBizkit', |                 sender: 'LimpBizkit', | ||||||
|                 receiver: 'FooFighters', |                 receiver: 'FooFighters', | ||||||
|                 classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', |                 classId: getClass02().classId, | ||||||
|             }, |             }, | ||||||
|         }; |         }; | ||||||
|         await getInvitationHandler(req as Request, res as Response); |         await getInvitationHandler(req as Request, res as Response); | ||||||
|  | @ -100,7 +101,7 @@ describe('Teacher controllers', () => { | ||||||
|         const body = { |         const body = { | ||||||
|             sender: 'LimpBizkit', |             sender: 'LimpBizkit', | ||||||
|             receiver: 'FooFighters', |             receiver: 'FooFighters', | ||||||
|             class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', |             class: getClass02().classId, | ||||||
|         } as TeacherInvitationData; |         } as TeacherInvitationData; | ||||||
|         req = { body }; |         req = { body }; | ||||||
| 
 | 
 | ||||||
|  | @ -111,7 +112,7 @@ describe('Teacher controllers', () => { | ||||||
| 
 | 
 | ||||||
|         req = { |         req = { | ||||||
|             params: { |             params: { | ||||||
|                 id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', |                 id: getClass02().classId, | ||||||
|             }, |             }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -15,8 +15,8 @@ import { | ||||||
| import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; | import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; | ||||||
| import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; | import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; | ||||||
| import { getStudentRequestsHandler } from '../../src/controllers/students.js'; | import { getStudentRequestsHandler } from '../../src/controllers/students.js'; | ||||||
| import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; |  | ||||||
| import { getClassHandler } from '../../src/controllers/classes'; | import { getClassHandler } from '../../src/controllers/classes'; | ||||||
|  | import { getClass02 } from '../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('Teacher controllers', () => { | describe('Teacher controllers', () => { | ||||||
|     let req: Partial<Request>; |     let req: Partial<Request>; | ||||||
|  | @ -96,7 +96,7 @@ describe('Teacher controllers', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('Teacher list', async () => { |     it('Teacher list', async () => { | ||||||
|         req = { query: { full: 'true' } }; |         req = { query: { full: 'false' } }; | ||||||
| 
 | 
 | ||||||
|         await getAllTeachersHandler(req as Request, res as Response); |         await getAllTeachersHandler(req as Request, res as Response); | ||||||
| 
 | 
 | ||||||
|  | @ -104,8 +104,7 @@ describe('Teacher controllers', () => { | ||||||
| 
 | 
 | ||||||
|         const result = jsonMock.mock.lastCall?.[0]; |         const result = jsonMock.mock.lastCall?.[0]; | ||||||
| 
 | 
 | ||||||
|         const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); |         expect(result.teachers).toContain('testleerkracht1'); | ||||||
|         expect(teacherUsernames).toContain('testleerkracht1'); |  | ||||||
| 
 | 
 | ||||||
|         expect(result.teachers).toHaveLength(5); |         expect(result.teachers).toHaveLength(5); | ||||||
|     }); |     }); | ||||||
|  | @ -169,7 +168,7 @@ describe('Teacher controllers', () => { | ||||||
| 
 | 
 | ||||||
|     it('Get join requests by class', async () => { |     it('Get join requests by class', async () => { | ||||||
|         req = { |         req = { | ||||||
|             params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             params: { classId: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await getStudentJoinRequestHandler(req as Request, res as Response); |         await getStudentJoinRequestHandler(req as Request, res as Response); | ||||||
|  | @ -183,7 +182,7 @@ describe('Teacher controllers', () => { | ||||||
| 
 | 
 | ||||||
|     it('Update join request status', async () => { |     it('Update join request status', async () => { | ||||||
|         req = { |         req = { | ||||||
|             params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', studentUsername: 'PinkFloyd' }, |             params: { classId: getClass02().classId, studentUsername: 'PinkFloyd' }, | ||||||
|             body: { accepted: 'true' }, |             body: { accepted: 'true' }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -201,7 +200,7 @@ describe('Teacher controllers', () => { | ||||||
|         expect(status).toBeTruthy(); |         expect(status).toBeTruthy(); | ||||||
| 
 | 
 | ||||||
|         req = { |         req = { | ||||||
|             params: { id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, |             params: { id: getClass02().classId }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         await getClassHandler(req as Request, res as Response); |         await getClassHandler(req as Request, res as Response); | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ import { setupTestApp } from '../../setup-tests'; | ||||||
| import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | ||||||
| import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; | import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; | ||||||
| import { ClassRepository } from '../../../src/data/classes/class-repository'; | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | import { getClass02 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('AssignmentRepository', () => { | describe('AssignmentRepository', () => { | ||||||
|     let assignmentRepository: AssignmentRepository; |     let assignmentRepository: AssignmentRepository; | ||||||
|  | @ -15,7 +16,7 @@ describe('AssignmentRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return the requested assignment', async () => { |     it('should return the requested assignment', async () => { | ||||||
|         const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); |         const class_ = await classRepository.findById(getClass02().classId); | ||||||
|         const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); |         const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); | ||||||
| 
 | 
 | ||||||
|         expect(assignment).toBeTruthy(); |         expect(assignment).toBeTruthy(); | ||||||
|  | @ -23,7 +24,7 @@ describe('AssignmentRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return all assignments for a class', async () => { |     it('should return all assignments for a class', async () => { | ||||||
|         const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); |         const class_ = await classRepository.findById(getClass02().classId); | ||||||
|         const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); |         const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); | ||||||
| 
 | 
 | ||||||
|         expect(assignments).toBeTruthy(); |         expect(assignments).toBeTruthy(); | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import { GroupRepository } from '../../../src/data/assignments/group-repository' | ||||||
| import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; | import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; | ||||||
| import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | ||||||
| import { ClassRepository } from '../../../src/data/classes/class-repository'; | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('GroupRepository', () => { | describe('GroupRepository', () => { | ||||||
|     let groupRepository: GroupRepository; |     let groupRepository: GroupRepository; | ||||||
|  | @ -18,7 +19,8 @@ describe('GroupRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return the requested group', async () => { |     it('should return the requested group', async () => { | ||||||
|         const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         const id = getClass01().classId; | ||||||
|  |         const class_ = await classRepository.findById(id); | ||||||
|         const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); |         const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); | ||||||
| 
 | 
 | ||||||
|         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); |         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); | ||||||
|  | @ -27,7 +29,7 @@ describe('GroupRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return all groups for assignment', async () => { |     it('should return all groups for assignment', async () => { | ||||||
|         const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         const class_ = await classRepository.findById(getClass01().classId); | ||||||
|         const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); |         const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); | ||||||
| 
 | 
 | ||||||
|         const groups = await groupRepository.findAllGroupsForAssignment(assignment!); |         const groups = await groupRepository.findAllGroupsForAssignment(assignment!); | ||||||
|  | @ -37,7 +39,7 @@ describe('GroupRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should not find removed group', async () => { |     it('should not find removed group', async () => { | ||||||
|         const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); |         const class_ = await classRepository.findById(getClass02().classId); | ||||||
|         const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); |         const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); | ||||||
| 
 | 
 | ||||||
|         await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001); |         await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001); | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ import { Submission } from '../../../src/entities/assignments/submission.entity' | ||||||
| import { Class } from '../../../src/entities/classes/class.entity'; | import { Class } from '../../../src/entities/classes/class.entity'; | ||||||
| import { Assignment } from '../../../src/entities/assignments/assignment.entity'; | import { Assignment } from '../../../src/entities/assignments/assignment.entity'; | ||||||
| import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; | import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; | ||||||
|  | import { getClass01 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('SubmissionRepository', () => { | describe('SubmissionRepository', () => { | ||||||
|     let submissionRepository: SubmissionRepository; |     let submissionRepository: SubmissionRepository; | ||||||
|  | @ -54,7 +55,7 @@ describe('SubmissionRepository', () => { | ||||||
| 
 | 
 | ||||||
|     it('should find the most recent submission for a group', async () => { |     it('should find the most recent submission for a group', async () => { | ||||||
|         const id = new LearningObjectIdentifier('id03', Language.English, 1); |         const id = new LearningObjectIdentifier('id03', Language.English, 1); | ||||||
|         const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         const class_ = await classRepository.findById(getClass01().classId); | ||||||
|         const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); |         const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); | ||||||
|         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); |         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); | ||||||
|         const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); |         const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); | ||||||
|  | @ -67,7 +68,7 @@ describe('SubmissionRepository', () => { | ||||||
|     let assignment: Assignment | null; |     let assignment: Assignment | null; | ||||||
|     let loId: LearningObjectIdentifier; |     let loId: LearningObjectIdentifier; | ||||||
|     it('should find all submissions for a certain learning object and assignment', async () => { |     it('should find all submissions for a certain learning object and assignment', async () => { | ||||||
|         clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         clazz = await classRepository.findById(getClass01().classId); | ||||||
|         assignment = await assignmentRepository.findByClassAndId(clazz!, 21000); |         assignment = await assignmentRepository.findByClassAndId(clazz!, 21000); | ||||||
|         loId = { |         loId = { | ||||||
|             hruid: 'id02', |             hruid: 'id02', | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join | ||||||
| import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; | import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; | ||||||
| import { StudentRepository } from '../../../src/data/users/student-repository'; | import { StudentRepository } from '../../../src/data/users/student-repository'; | ||||||
| import { ClassRepository } from '../../../src/data/classes/class-repository'; | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | import { getClass02, getClass03 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('ClassJoinRequestRepository', () => { | describe('ClassJoinRequestRepository', () => { | ||||||
|     let classJoinRequestRepository: ClassJoinRequestRepository; |     let classJoinRequestRepository: ClassJoinRequestRepository; | ||||||
|  | @ -26,7 +27,7 @@ describe('ClassJoinRequestRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should list all requests to a single class', async () => { |     it('should list all requests to a single class', async () => { | ||||||
|         const class_ = await cassRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); |         const class_ = await cassRepository.findById(getClass02().classId); | ||||||
|         const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); |         const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); | ||||||
| 
 | 
 | ||||||
|         expect(requests).toBeTruthy(); |         expect(requests).toBeTruthy(); | ||||||
|  | @ -35,7 +36,7 @@ describe('ClassJoinRequestRepository', () => { | ||||||
| 
 | 
 | ||||||
|     it('should not find a removed request', async () => { |     it('should not find a removed request', async () => { | ||||||
|         const student = await studentRepository.findByUsername('SmashingPumpkins'); |         const student = await studentRepository.findByUsername('SmashingPumpkins'); | ||||||
|         const class_ = await cassRepository.findById('80dcc3e0-1811-4091-9361-42c0eee91cfa'); |         const class_ = await cassRepository.findById(getClass03().classId); | ||||||
|         await classJoinRequestRepository.deleteBy(student!, class_!); |         await classJoinRequestRepository.deleteBy(student!, class_!); | ||||||
| 
 | 
 | ||||||
|         const request = await classJoinRequestRepository.findAllRequestsBy(student!); |         const request = await classJoinRequestRepository.findAllRequestsBy(student!); | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
| import { ClassRepository } from '../../../src/data/classes/class-repository'; | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
| import { setupTestApp } from '../../setup-tests'; | import { setupTestApp } from '../../setup-tests'; | ||||||
| import { getClassRepository } from '../../../src/data/repositories'; | import { getClassRepository } from '../../../src/data/repositories'; | ||||||
|  | import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('ClassRepository', () => { | describe('ClassRepository', () => { | ||||||
|     let classRepository: ClassRepository; |     let classRepository: ClassRepository; | ||||||
|  | @ -18,16 +19,16 @@ describe('ClassRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return requested class', async () => { |     it('should return requested class', async () => { | ||||||
|         const classVar = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         const classVar = await classRepository.findById(getClass01().classId); | ||||||
| 
 | 
 | ||||||
|         expect(classVar).toBeTruthy(); |         expect(classVar).toBeTruthy(); | ||||||
|         expect(classVar?.displayName).toBe('class01'); |         expect(classVar?.displayName).toBe('class01'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('class should be gone after deletion', async () => { |     it('class should be gone after deletion', async () => { | ||||||
|         await classRepository.deleteById('33d03536-83b8-4880-9982-9bbf2f908ddf'); |         await classRepository.deleteById(getClass04().classId); | ||||||
| 
 | 
 | ||||||
|         const classVar = await classRepository.findById('33d03536-83b8-4880-9982-9bbf2f908ddf'); |         const classVar = await classRepository.findById(getClass04().classId); | ||||||
| 
 | 
 | ||||||
|         expect(classVar).toBeNull(); |         expect(classVar).toBeNull(); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import { getClassRepository, getTeacherInvitationRepository, getTeacherRepositor | ||||||
| import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; | import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; | ||||||
| import { TeacherRepository } from '../../../src/data/users/teacher-repository'; | import { TeacherRepository } from '../../../src/data/users/teacher-repository'; | ||||||
| import { ClassRepository } from '../../../src/data/classes/class-repository'; | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('ClassRepository', () => { | describe('ClassRepository', () => { | ||||||
|     let teacherInvitationRepository: TeacherInvitationRepository; |     let teacherInvitationRepository: TeacherInvitationRepository; | ||||||
|  | @ -34,7 +35,7 @@ describe('ClassRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return all invitations for a class', async () => { |     it('should return all invitations for a class', async () => { | ||||||
|         const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); |         const class_ = await classRepository.findById(getClass02().classId); | ||||||
|         const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); |         const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); | ||||||
| 
 | 
 | ||||||
|         expect(invitations).toBeTruthy(); |         expect(invitations).toBeTruthy(); | ||||||
|  | @ -42,7 +43,7 @@ describe('ClassRepository', () => { | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should not find a removed invitation', async () => { |     it('should not find a removed invitation', async () => { | ||||||
|         const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         const class_ = await classRepository.findById(getClass01().classId); | ||||||
|         const sender = await teacherRepository.findByUsername('FooFighters'); |         const sender = await teacherRepository.findByUsername('FooFighters'); | ||||||
|         const receiver = await teacherRepository.findByUsername('LimpBizkit'); |         const receiver = await teacherRepository.findByUsername('LimpBizkit'); | ||||||
|         await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); |         await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); | ||||||
|  |  | ||||||
|  | @ -14,6 +14,7 @@ import { Language } from '@dwengo-1/common/util/language'; | ||||||
| import { Question } from '../../../src/entities/questions/question.entity'; | import { Question } from '../../../src/entities/questions/question.entity'; | ||||||
| import { Class } from '../../../src/entities/classes/class.entity'; | import { Class } from '../../../src/entities/classes/class.entity'; | ||||||
| import { Assignment } from '../../../src/entities/assignments/assignment.entity'; | import { Assignment } from '../../../src/entities/assignments/assignment.entity'; | ||||||
|  | import { getClass01 } from '../../test_assets/classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| describe('QuestionRepository', () => { | describe('QuestionRepository', () => { | ||||||
|     let questionRepository: QuestionRepository; |     let questionRepository: QuestionRepository; | ||||||
|  | @ -37,7 +38,7 @@ describe('QuestionRepository', () => { | ||||||
|         const id = new LearningObjectIdentifier('id03', Language.English, 1); |         const id = new LearningObjectIdentifier('id03', Language.English, 1); | ||||||
|         const student = await studentRepository.findByUsername('Noordkaap'); |         const student = await studentRepository.findByUsername('Noordkaap'); | ||||||
| 
 | 
 | ||||||
|         const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         const clazz = await getClassRepository().findById(getClass01().classId); | ||||||
|         const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); |         const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); | ||||||
|         const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001); |         const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001); | ||||||
|         await questionRepository.createQuestion({ |         await questionRepository.createQuestion({ | ||||||
|  | @ -56,7 +57,7 @@ describe('QuestionRepository', () => { | ||||||
|     let assignment: Assignment | null; |     let assignment: Assignment | null; | ||||||
|     let loId: LearningObjectIdentifier; |     let loId: LearningObjectIdentifier; | ||||||
|     it('should find all questions for a certain learning object and assignment', async () => { |     it('should find all questions for a certain learning object and assignment', async () => { | ||||||
|         clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); |         clazz = await getClassRepository().findById(getClass01().classId); | ||||||
|         assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); |         assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); | ||||||
|         loId = { |         loId = { | ||||||
|             hruid: 'id05', |             hruid: 'id05', | ||||||
|  |  | ||||||
|  | @ -6,13 +6,20 @@ import { testLearningPathWithConditions } from '../content/learning-paths.testda | ||||||
| import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; | import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; | ||||||
| 
 | 
 | ||||||
| export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] { | export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] { | ||||||
|  |     const futureDate = new Date(); | ||||||
|  |     futureDate.setDate(futureDate.getDate() + 7); | ||||||
|  |     const pastDate = new Date(); | ||||||
|  |     pastDate.setDate(pastDate.getDate() - 7); | ||||||
|  |     const today = new Date(); | ||||||
|  |     today.setHours(23, 59); | ||||||
|     assignment01 = em.create(Assignment, { |     assignment01 = em.create(Assignment, { | ||||||
|         id: 21000, |         id: 21000, | ||||||
|         within: classes[0], |         within: classes[0], | ||||||
|         title: 'dire straits', |         title: 'dire straits', | ||||||
|         description: 'reading', |         description: 'reading', | ||||||
|         learningPathHruid: 'id02', |         learningPathHruid: 'un_ai', | ||||||
|         learningPathLanguage: Language.English, |         learningPathLanguage: Language.English, | ||||||
|  |         deadline: today, | ||||||
|         groups: [], |         groups: [], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -23,6 +30,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign | ||||||
|         description: 'reading', |         description: 'reading', | ||||||
|         learningPathHruid: 'id01', |         learningPathHruid: 'id01', | ||||||
|         learningPathLanguage: Language.English, |         learningPathLanguage: Language.English, | ||||||
|  |         deadline: futureDate, | ||||||
|         groups: [], |         groups: [], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -33,6 +41,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign | ||||||
|         description: 'will be deleted', |         description: 'will be deleted', | ||||||
|         learningPathHruid: 'id02', |         learningPathHruid: 'id02', | ||||||
|         learningPathLanguage: Language.English, |         learningPathLanguage: Language.English, | ||||||
|  |         deadline: pastDate, | ||||||
|         groups: [], |         groups: [], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -43,6 +52,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign | ||||||
|         description: 'with a description', |         description: 'with a description', | ||||||
|         learningPathHruid: 'id01', |         learningPathHruid: 'id01', | ||||||
|         learningPathLanguage: Language.English, |         learningPathLanguage: Language.English, | ||||||
|  |         deadline: pastDate, | ||||||
|         groups: [], |         groups: [], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -53,6 +63,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign | ||||||
|         description: 'You have to do the testing learning path with a condition.', |         description: 'You have to do the testing learning path with a condition.', | ||||||
|         learningPathHruid: testLearningPathWithConditions.hruid, |         learningPathHruid: testLearningPathWithConditions.hruid, | ||||||
|         learningPathLanguage: testLearningPathWithConditions.language as Language, |         learningPathLanguage: testLearningPathWithConditions.language as Language, | ||||||
|  |         deadline: futureDate, | ||||||
|         groups: [], |         groups: [], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers | ||||||
|     const teacherClass01: Teacher[] = teachers.slice(4, 5); |     const teacherClass01: Teacher[] = teachers.slice(4, 5); | ||||||
| 
 | 
 | ||||||
|     class01 = em.create(Class, { |     class01 = em.create(Class, { | ||||||
|         classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', |         classId: 'X2J9QT', // 8764b861-90a6-42e5-9732-c0d9eb2f55f9
 | ||||||
|         displayName: 'class01', |         displayName: 'class01', | ||||||
|         teachers: teacherClass01, |         teachers: teacherClass01, | ||||||
|         students: studentsClass01, |         students: studentsClass01, | ||||||
|  | @ -20,7 +20,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers | ||||||
|     const teacherClass02: Teacher[] = teachers.slice(1, 2); |     const teacherClass02: Teacher[] = teachers.slice(1, 2); | ||||||
| 
 | 
 | ||||||
|     class02 = em.create(Class, { |     class02 = em.create(Class, { | ||||||
|         classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', |         classId: '7KLPMA', // 34d484a1-295f-4e9f-bfdc-3e7a23d86a89
 | ||||||
|         displayName: 'class02', |         displayName: 'class02', | ||||||
|         teachers: teacherClass02, |         teachers: teacherClass02, | ||||||
|         students: studentsClass02, |         students: studentsClass02, | ||||||
|  | @ -30,7 +30,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers | ||||||
|     const teacherClass03: Teacher[] = teachers.slice(2, 3); |     const teacherClass03: Teacher[] = teachers.slice(2, 3); | ||||||
| 
 | 
 | ||||||
|     class03 = em.create(Class, { |     class03 = em.create(Class, { | ||||||
|         classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', |         classId: 'R0D3UZ', // 80dcc3e0-1811-4091-9361-42c0eee91cfa
 | ||||||
|         displayName: 'class03', |         displayName: 'class03', | ||||||
|         teachers: teacherClass03, |         teachers: teacherClass03, | ||||||
|         students: studentsClass03, |         students: studentsClass03, | ||||||
|  | @ -40,14 +40,14 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers | ||||||
|     const teacherClass04: Teacher[] = teachers.slice(2, 3); |     const teacherClass04: Teacher[] = teachers.slice(2, 3); | ||||||
| 
 | 
 | ||||||
|     class04 = em.create(Class, { |     class04 = em.create(Class, { | ||||||
|         classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', |         classId: 'Q8N5YC', // 33d03536-83b8-4880-9982-9bbf2f908ddf
 | ||||||
|         displayName: 'class04', |         displayName: 'class04', | ||||||
|         teachers: teacherClass04, |         teachers: teacherClass04, | ||||||
|         students: studentsClass04, |         students: studentsClass04, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     classWithTestleerlingAndTestleerkracht = em.create(Class, { |     classWithTestleerlingAndTestleerkracht = em.create(Class, { | ||||||
|         classId: 'a75298b5-18aa-471d-8eeb-5d77eb989393', |         classId: 'ZAV71B', // Was a75298b5-18aa-471d-8eeb-5d77eb989393
 | ||||||
|         displayName: 'Testklasse', |         displayName: 'Testklasse', | ||||||
|         teachers: [getTestleerkracht1()], |         teachers: [getTestleerkracht1()], | ||||||
|         students: [getTestleerling1()], |         students: [getTestleerling1()], | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ export interface AssignmentDTO { | ||||||
|     description: string; |     description: string; | ||||||
|     learningPath: string; |     learningPath: string; | ||||||
|     language: string; |     language: string; | ||||||
|  |     deadline: Date; | ||||||
|     groups: GroupDTO[] | string[][]; |     groups: GroupDTO[] | string[][]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -25,8 +25,8 @@ export interface LearningObjectNode { | ||||||
|     language: Language; |     language: Language; | ||||||
|     start_node?: boolean; |     start_node?: boolean; | ||||||
|     transitions: Transition[]; |     transitions: Transition[]; | ||||||
|     created_at: string; |     created_at?: string; | ||||||
|     updatedAt: string; |     updatedAt?: string; | ||||||
|     done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
 |     done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -79,6 +79,8 @@ export interface LearningObjectMetadata { | ||||||
|     target_ages: number[]; |     target_ages: number[]; | ||||||
|     content_type: string; // Markdown, image, etc.
 |     content_type: string; // Markdown, image, etc.
 | ||||||
|     content_location?: string; |     content_location?: string; | ||||||
|  |     copyright?: string; | ||||||
|  |     license?: string; | ||||||
|     skos_concepts?: string[]; |     skos_concepts?: string[]; | ||||||
|     return_value?: ReturnValue; |     return_value?: ReturnValue; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -13,8 +13,8 @@ export interface QuestionDTO { | ||||||
| 
 | 
 | ||||||
| export interface QuestionData { | export interface QuestionData { | ||||||
|     author?: string; |     author?: string; | ||||||
|     content: string; |  | ||||||
|     inGroup: GroupDTO; |     inGroup: GroupDTO; | ||||||
|  |     content: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface QuestionId { | export interface QuestionId { | ||||||
|  |  | ||||||
							
								
								
									
										4
									
								
								common/src/util/account-types.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								common/src/util/account-types.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | ||||||
|  | export enum AccountType { | ||||||
|  |     Student = 'student', | ||||||
|  |     Teacher = 'teacher', | ||||||
|  | } | ||||||
|  | @ -67,8 +67,6 @@ services: | ||||||
|             - 'traefik.enable=true' |             - 'traefik.enable=true' | ||||||
|             - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' |             - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' | ||||||
|             - 'traefik.http.services.idp.loadbalancer.server.port=7080' |             - 'traefik.http.services.idp.loadbalancer.server.port=7080' | ||||||
|             - 'traefik.http.routers.block-admin.rule=PathPrefix(`/idp/admin`)' |  | ||||||
|             - 'traefik.http.routers.block-admin.service=web' |  | ||||||
|         depends_on: |         depends_on: | ||||||
|             - keycloak-db |             - keycloak-db | ||||||
|         volumes: |         volumes: | ||||||
|  | @ -95,6 +93,9 @@ services: | ||||||
|             - '80:80/tcp' |             - '80:80/tcp' | ||||||
|             - '443:443/tcp' |             - '443:443/tcp' | ||||||
|         command: |         command: | ||||||
|  |             # Enable web UI | ||||||
|  |             - '--api=true' | ||||||
|  | 
 | ||||||
|             # Add Docker provider |             # Add Docker provider | ||||||
|             - '--providers.docker=true' |             - '--providers.docker=true' | ||||||
|             - '--providers.docker.exposedbydefault=false' |             - '--providers.docker.exposedbydefault=false' | ||||||
|  | @ -115,6 +116,17 @@ services: | ||||||
|             - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' |             - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' | ||||||
|             - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' |             - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' | ||||||
|             - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' |             - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' | ||||||
|  |         labels: | ||||||
|  |             # BasicAuth middleware | ||||||
|  |             # To create a user:password pair, the following command can be used: | ||||||
|  |             # echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g | ||||||
|  |             - 'traefik.http.middlewares.protected-sub-path.basicauth.users=dwengo.org:$$apr1$$FdALqAjI$$7ZhPq0I/qEQ6k3OYqxJKZ1' | ||||||
|  |             # Proxying | ||||||
|  |             - 'traefik.enable=true' | ||||||
|  |             - 'traefik.http.routers.proxy.middlewares=protected-sub-path' | ||||||
|  |             - 'traefik.http.routers.proxy.service=api@internal' | ||||||
|  |             - 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)' | ||||||
|  |             - 'traefik.http.services.proxy.loadbalancer.server.port=8080' | ||||||
|         restart: unless-stopped |         restart: unless-stopped | ||||||
|         volumes: |         volumes: | ||||||
|             - /var/run/docker.sock:/var/run/docker.sock:ro |             - /var/run/docker.sock:/var/run/docker.sock:ro | ||||||
|  | @ -137,8 +149,10 @@ services: | ||||||
| 
 | 
 | ||||||
|     dashboards: |     dashboards: | ||||||
|         image: grafana/grafana:latest |         image: grafana/grafana:latest | ||||||
|         ports: |         labels: | ||||||
|             - '9002:3000' |             - 'traefik.enable=true' | ||||||
|  |             - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' | ||||||
|  |             - 'traefik.http.services.graphs.loadbalancer.server.port=3000' | ||||||
|         restart: unless-stopped |         restart: unless-stopped | ||||||
|         volumes: |         volumes: | ||||||
|             - dwengo_grafana_data:/var/lib/grafana |             - dwengo_grafana_data:/var/lib/grafana | ||||||
|  |  | ||||||
|  | @ -60,6 +60,13 @@ services: | ||||||
| 
 | 
 | ||||||
|             # Add web entrypoint |             # Add web entrypoint | ||||||
|             - '--entrypoints.web.address=:80/tcp' |             - '--entrypoints.web.address=:80/tcp' | ||||||
|  | 
 | ||||||
|  |             # Proxying the web UI on a sub-path | ||||||
|  |             - '--api.basePath=/proxy' | ||||||
|  |         labels: | ||||||
|  |             - 'traefik.http.routers.proxy.service=api@internal' | ||||||
|  |             - 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)' | ||||||
|  |             - 'traefik.http.services.proxy.loadbalancer.server.port=8080' | ||||||
|         ports: |         ports: | ||||||
|             - '9000:8080' |             - '9000:8080' | ||||||
|             - '80:80/tcp' |             - '80:80/tcp' | ||||||
|  | @ -82,8 +89,12 @@ services: | ||||||
|         image: grafana/grafana:latest |         image: grafana/grafana:latest | ||||||
|         ports: |         ports: | ||||||
|             - '9002:3000' |             - '9002:3000' | ||||||
|  |         labels: | ||||||
|  |             - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' | ||||||
|  |             - 'traefik.http.services.graphs.loadbalancer.server.port=3000' | ||||||
|         volumes: |         volumes: | ||||||
|             - dwengo_grafana_data:/var/lib/grafana |             - dwengo_grafana_data:/var/lib/grafana | ||||||
|  |             - ./config/grafana/grafana.ini:/etc/grafana/grafana.ini | ||||||
|         restart: unless-stopped |         restart: unless-stopped | ||||||
| 
 | 
 | ||||||
| volumes: | volumes: | ||||||
|  |  | ||||||
							
								
								
									
										8
									
								
								config/grafana/grafana.ini
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								config/grafana/grafana.ini
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | ||||||
|  | [server] | ||||||
|  | 
 | ||||||
|  | root_url = http://localhost:3000/graphs | ||||||
|  | serve_from_sub_path = true | ||||||
|  | 
 | ||||||
|  | [security] | ||||||
|  | 
 | ||||||
|  | admin_user = dwengo.org | ||||||
|  | @ -26,7 +26,59 @@ const doc = { | ||||||
|     ], |     ], | ||||||
|     components: { |     components: { | ||||||
|         securitySchemes: { |         securitySchemes: { | ||||||
|             student: { |             studentDev: { | ||||||
|  |                 type: 'oauth2', | ||||||
|  |                 flows: { | ||||||
|  |                     implicit: { | ||||||
|  |                         authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth', | ||||||
|  |                         scopes: { | ||||||
|  |                             openid: 'openid', | ||||||
|  |                             profile: 'profile', | ||||||
|  |                             email: 'email', | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             teacherDev: { | ||||||
|  |                 type: 'oauth2', | ||||||
|  |                 flows: { | ||||||
|  |                     implicit: { | ||||||
|  |                         authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth', | ||||||
|  |                         scopes: { | ||||||
|  |                             openid: 'openid', | ||||||
|  |                             profile: 'profile', | ||||||
|  |                             email: 'email', | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             studentStaging: { | ||||||
|  |                 type: 'oauth2', | ||||||
|  |                 flows: { | ||||||
|  |                     implicit: { | ||||||
|  |                         authorizationUrl: 'http://localhost/idp/realms/student/protocol/openid-connect/auth', | ||||||
|  |                         scopes: { | ||||||
|  |                             openid: 'openid', | ||||||
|  |                             profile: 'profile', | ||||||
|  |                             email: 'email', | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             teacherStaging: { | ||||||
|  |                 type: 'oauth2', | ||||||
|  |                 flows: { | ||||||
|  |                     implicit: { | ||||||
|  |                         authorizationUrl: 'http://localhost/idp/realms/teacher/protocol/openid-connect/auth', | ||||||
|  |                         scopes: { | ||||||
|  |                             openid: 'openid', | ||||||
|  |                             profile: 'profile', | ||||||
|  |                             email: 'email', | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             studentProduction: { | ||||||
|                 type: 'oauth2', |                 type: 'oauth2', | ||||||
|                 flows: { |                 flows: { | ||||||
|                     implicit: { |                     implicit: { | ||||||
|  | @ -39,7 +91,7 @@ const doc = { | ||||||
|                     }, |                     }, | ||||||
|                 }, |                 }, | ||||||
|             }, |             }, | ||||||
|             teacher: { |             teacherProduction: { | ||||||
|                 type: 'oauth2', |                 type: 'oauth2', | ||||||
|                 flows: { |                 flows: { | ||||||
|                     implicit: { |                     implicit: { | ||||||
|  |  | ||||||
|  | @ -3,22 +3,28 @@ FROM node:22 AS build-stage | ||||||
| # install simple http server for serving static content | # install simple http server for serving static content | ||||||
| RUN npm install -g http-server | RUN npm install -g http-server | ||||||
| 
 | 
 | ||||||
| WORKDIR /app | WORKDIR /app/dwengo | ||||||
| 
 | 
 | ||||||
| # Install dependencies | # Install dependencies | ||||||
| 
 | 
 | ||||||
| COPY package*.json ./ | COPY package*.json ./ | ||||||
| COPY ./frontend/package.json ./frontend/ | COPY ./frontend/package.json ./frontend/ | ||||||
|  | # Frontend depends on common | ||||||
|  | COPY common/package.json ./common/ | ||||||
| 
 | 
 | ||||||
| RUN npm install --silent | RUN npm install --silent | ||||||
| 
 | 
 | ||||||
| # Build the frontend | # Build the frontend | ||||||
| 
 | 
 | ||||||
| # Root tsconfig.json | # Root tsconfig.json | ||||||
| COPY tsconfig.json ./ | COPY tsconfig.json tsconfig.build.json ./ | ||||||
| COPY assets ./assets/ |  | ||||||
| 
 | 
 | ||||||
| WORKDIR /app/frontend | COPY assets ./assets | ||||||
|  | COPY common ./common | ||||||
|  | 
 | ||||||
|  | RUN npm run build --workspace=common | ||||||
|  | 
 | ||||||
|  | WORKDIR /app/dwengo/frontend | ||||||
| 
 | 
 | ||||||
| COPY frontend ./ | COPY frontend ./ | ||||||
| 
 | 
 | ||||||
|  | @ -28,8 +34,8 @@ FROM nginx:stable AS production-stage | ||||||
| 
 | 
 | ||||||
| COPY config/nginx/nginx.conf /etc/nginx/nginx.conf | COPY config/nginx/nginx.conf /etc/nginx/nginx.conf | ||||||
| 
 | 
 | ||||||
| COPY --from=build-stage /app/assets /usr/share/nginx/html/assets | COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets | ||||||
| COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html | COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html | ||||||
| 
 | 
 | ||||||
| EXPOSE 8080 | EXPOSE 8080 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										81
									
								
								frontend/e2e/assignments.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								frontend/e2e/assignments.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,81 @@ | ||||||
|  | import { test, expect } from "@playwright/test"; | ||||||
|  | 
 | ||||||
|  | test("Teacher can create new assignment", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Go to assignments
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); | ||||||
|  |     await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "New Assignment" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     // Create new assignment
 | ||||||
|  |     await page.getByRole("button", { name: "New Assignment" }).click(); | ||||||
|  |     await expect(page.getByRole("button", { name: "submit" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("link", { name: "cancel" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("textbox", { name: "Title Title" }).fill("Assignment test 1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Select a learning path Select" }).click(); | ||||||
|  |     await page.getByText("Using notebooks").click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Pick a class Pick a class" }).click(); | ||||||
|  |     await page.getByText("class01").click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Select Deadline Select" }).fill("2099-01-01T12:34"); | ||||||
|  |     await page.getByRole("textbox", { name: "Description Description" }).fill("Assignment description"); | ||||||
|  | 
 | ||||||
|  |     await page.getByRole("button", { name: "submit" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByText("Assignment test")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("main").getByRole("button").first()).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("main")).toContainText("Assignment test 1"); | ||||||
|  |     await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("main")).toContainText("Assignment description"); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Student can see list of assignments", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "student" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Go to assignments
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); | ||||||
|  |     await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible(); | ||||||
|  |     await expect(page.getByText("dire straits")).toBeVisible(); | ||||||
|  |     await expect(page.locator(".button-row > .v-btn").first()).toBeVisible(); | ||||||
|  |     await expect(page.getByText("Class: class01").first()).toBeVisible(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Student can see assignment details", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "student" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Go to assignments
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); | ||||||
|  |     await expect(page.getByText("Assignment: Conditional")).toBeVisible(); | ||||||
|  |     await expect(page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn")).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     // View assignment details
 | ||||||
|  |     await page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn").click(); | ||||||
|  |     await expect(page.getByText("Assignment: Conditional")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("progressbar").locator("div").first()).toBeVisible(); | ||||||
|  | }); | ||||||
|  | @ -1,8 +1,16 @@ | ||||||
| import { test, expect } from "./fixtures.js"; | import { test, expect } from "@playwright/test"; | ||||||
| 
 | 
 | ||||||
| test("Users can filter", async ({ page }) => { | test("Users can filter", async ({ page }) => { | ||||||
|     await page.goto("/user"); |     await page.goto("/"); | ||||||
| 
 | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Filter
 | ||||||
|     await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click(); |     await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click(); | ||||||
|     await page.getByText("Nature and climate").click(); |     await page.getByText("Nature and climate").click(); | ||||||
|     await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); |     await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); | ||||||
|  |  | ||||||
|  | @ -1,5 +0,0 @@ | ||||||
| import { test, expect } from "./fixtures.js"; |  | ||||||
| 
 |  | ||||||
| test("myTest", async ({ page }) => { |  | ||||||
|     await expect(page).toHaveURL("/"); |  | ||||||
| }); |  | ||||||
							
								
								
									
										107
									
								
								frontend/e2e/class.spec.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								frontend/e2e/class.spec.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,107 @@ | ||||||
|  | import { test, expect } from "@playwright/test"; | ||||||
|  | 
 | ||||||
|  | test("Teacher can create a class", async ({ page }) => { | ||||||
|  |     const className = "DeTijdLoze"; | ||||||
|  | 
 | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Go to class
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Check if the class page is visible
 | ||||||
|  |     await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("textbox", { name: "classname classname" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "create" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     // Create a class
 | ||||||
|  |     await page.getByRole("textbox", { name: "classname classname" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "classname classname" }).fill(className); | ||||||
|  |     await page.getByRole("button", { name: "create" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Check if the class is created
 | ||||||
|  |     await expect(page.getByRole("dialog").getByText("code")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "close" })).toBeVisible(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Teacher can share a class by code", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Go to classes
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("row", { name: "class01" }).locator("i").nth(1)).toBeVisible(); | ||||||
|  |     await page.getByRole("row", { name: "class01" }).locator("i").nth(1).click(); | ||||||
|  |     await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(2)).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(3)).toBeVisible(); | ||||||
|  |     await page.getByRole("button").filter({ hasText: /^$/ }).nth(3).click(); | ||||||
|  |     await expect(page.getByText("copied!")).toBeVisible(); | ||||||
|  |     await page.getByRole("button", { name: "close" }).click(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Student can join class by code", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "student" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Go to class
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); | ||||||
|  | 
 | ||||||
|  |     // Check if the class page is visible
 | ||||||
|  |     await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("heading", { name: "Join class" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("textbox", { name: "CODE CODE" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "submit" })).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |     // Join a class
 | ||||||
|  |     await page.getByRole("textbox", { name: "CODE CODE" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "CODE CODE" }).fill("X2J9QT"); | ||||||
|  |     await page.getByRole("button", { name: "submit" }).click(); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test("Teacher can remove student from class", async ({ page }) => { | ||||||
|  |     await page.goto("/"); | ||||||
|  | 
 | ||||||
|  |     // Login
 | ||||||
|  |     await page.getByRole("link", { name: "log in" }).click(); | ||||||
|  |     await page.getByRole("button", { name: "teacher" }).click(); | ||||||
|  |     await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); | ||||||
|  |     await page.getByRole("textbox", { name: "Password" }).fill("password"); | ||||||
|  |     await page.getByRole("button", { name: "Sign In" }).click(); | ||||||
|  | 
 | ||||||
|  |     await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); | ||||||
|  |     await expect(page.getByRole("link", { name: "class01" })).toBeVisible(); | ||||||
|  |     await expect(page.locator("#app")).toContainText("8"); | ||||||
|  |     await page.getByRole("link", { name: "class01" }).click(); | ||||||
|  |     await expect(page.getByRole("cell", { name: "Kurt Cobain" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button")).toBeVisible(); | ||||||
|  |     await page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button").click(); | ||||||
|  |     await expect(page.getByText("Are you sure?")).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "cancel" })).toBeVisible(); | ||||||
|  |     await expect(page.getByRole("button", { name: "yes" })).toBeVisible(); | ||||||
|  |     await page.getByRole("button", { name: "yes" }).click(); | ||||||
|  |     await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); | ||||||
|  |     await expect(page.locator("#app")).toContainText("7"); | ||||||
|  | }); | ||||||
|  | @ -17,18 +17,18 @@ | ||||||
|         "test:e2e": "playwright test" |         "test:e2e": "playwright test" | ||||||
|     }, |     }, | ||||||
|     "dependencies": { |     "dependencies": { | ||||||
|  |         "@dwengo-1/common": "^0.2.0", | ||||||
|         "@tanstack/react-query": "^5.69.0", |         "@tanstack/react-query": "^5.69.0", | ||||||
|         "@tanstack/vue-query": "^5.69.0", |         "@tanstack/vue-query": "^5.69.0", | ||||||
|         "@vueuse/core": "^13.1.0", |         "@vueuse/core": "^13.1.0", | ||||||
|         "axios": "^1.8.2", |         "axios": "^1.8.2", | ||||||
|         "interactjs": "^1.10.27", |         "json-editor-vue": "^0.18.1", | ||||||
|         "oidc-client-ts": "^3.1.0", |         "oidc-client-ts": "^3.1.0", | ||||||
|         "rollup": "^4.40.0", |         "rollup": "^4.40.0", | ||||||
|         "uuid": "^11.1.0", |         "uuid": "^11.1.0", | ||||||
|         "vue": "^3.5.13", |         "vue": "^3.5.13", | ||||||
|         "vue-i18n": "^11.1.2", |         "vue-i18n": "^11.1.2", | ||||||
|         "vue-router": "^4.5.0", |         "vue-router": "^4.5.0", | ||||||
|         "vuedraggable": "^2.24.3", |  | ||||||
|         "vuetify": "^3.7.12", |         "vuetify": "^3.7.12", | ||||||
|         "wait-on": "^8.0.3" |         "wait-on": "^8.0.3" | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
							
								
								
									
										54
									
								
								frontend/src/assets/common.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								frontend/src/assets/common.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | ||||||
|  | .h1 { | ||||||
|  |     color: #0e6942; | ||||||
|  |     text-transform: uppercase; | ||||||
|  |     font-weight: bolder; | ||||||
|  |     font-size: 50px; | ||||||
|  |     padding-left: 1%; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .empty-message { | ||||||
|  |     text-align: center; | ||||||
|  |     font-size: 18px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .header { | ||||||
|  |     font-weight: bold !important; | ||||||
|  |     background-color: #0e6942; | ||||||
|  |     color: white; | ||||||
|  |     padding: 10px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table thead th:first-child { | ||||||
|  |     border-top-left-radius: 10px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table thead th:last-child { | ||||||
|  |     border-top-right-radius: 10px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table tbody tr:nth-child(odd) { | ||||||
|  |     background-color: white; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table tbody tr:nth-child(even) { | ||||||
|  |     background-color: #f6faf2; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table td, | ||||||
|  | .table th { | ||||||
|  |     border-bottom: 1px solid #0e6942; | ||||||
|  |     border-top: 1px solid #0e6942; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table { | ||||||
|  |     width: 90%; | ||||||
|  |     padding-top: 10px; | ||||||
|  |     border-collapse: collapse; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @media screen and (max-width: 850px) { | ||||||
|  |     .h1 { | ||||||
|  |         text-align: center; | ||||||
|  |         padding-left: 0; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -5,6 +5,7 @@ | ||||||
|     import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; |     import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; | ||||||
|     import { useThemeQuery } from "@/queries/themes.ts"; |     import { useThemeQuery } from "@/queries/themes.ts"; | ||||||
|     import type { Theme } from "@/data-objects/theme.ts"; |     import type { Theme } from "@/data-objects/theme.ts"; | ||||||
|  |     import authService from "@/services/auth/auth-service"; | ||||||
| 
 | 
 | ||||||
|     const props = defineProps({ |     const props = defineProps({ | ||||||
|         selectedTheme: { type: String, required: true }, |         selectedTheme: { type: String, required: true }, | ||||||
|  | @ -33,6 +34,8 @@ | ||||||
|             cards.value = themes; |             cards.value = themes; | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     const isTeacher = computed(() => authService.authState.activeRole === "teacher"); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|  | @ -57,6 +60,39 @@ | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <v-row v-else> |         <v-row v-else> | ||||||
|  |             <v-col | ||||||
|  |                 cols="12" | ||||||
|  |                 sm="6" | ||||||
|  |                 md="4" | ||||||
|  |                 lg="4" | ||||||
|  |                 class="d-flex" | ||||||
|  |             > | ||||||
|  |                 <ThemeCard | ||||||
|  |                     path="/learningPath/search" | ||||||
|  |                     :is-absolute-path="true" | ||||||
|  |                     :title="t('searchAllLearningPathsTitle')" | ||||||
|  |                     :description="t('searchAllLearningPathsDescription')" | ||||||
|  |                     icon="mdi-magnify" | ||||||
|  |                     class="fill-height grey-bg-card" | ||||||
|  |                 /> | ||||||
|  |             </v-col> | ||||||
|  |             <v-col | ||||||
|  |                 v-if="isTeacher" | ||||||
|  |                 cols="12" | ||||||
|  |                 sm="6" | ||||||
|  |                 md="4" | ||||||
|  |                 lg="4" | ||||||
|  |                 class="d-flex" | ||||||
|  |             > | ||||||
|  |                 <ThemeCard | ||||||
|  |                     path="/my-content" | ||||||
|  |                     :is-absolute-path="true" | ||||||
|  |                     :title="t('ownLearningContentTitle')" | ||||||
|  |                     :description="t('ownLearningContentDescription')" | ||||||
|  |                     icon="mdi-pencil" | ||||||
|  |                     class="fill-height grey-bg-card" | ||||||
|  |                 /> | ||||||
|  |             </v-col> | ||||||
|             <v-col |             <v-col | ||||||
|                 v-for="card in cards" |                 v-for="card in cards" | ||||||
|                 :key="card.key" |                 :key="card.key" | ||||||
|  | @ -74,24 +110,13 @@ | ||||||
|                     class="fill-height" |                     class="fill-height" | ||||||
|                 /> |                 /> | ||||||
|             </v-col> |             </v-col> | ||||||
|             <v-col |  | ||||||
|                 cols="12" |  | ||||||
|                 sm="6" |  | ||||||
|                 md="4" |  | ||||||
|                 lg="4" |  | ||||||
|                 class="d-flex" |  | ||||||
|             > |  | ||||||
|                 <ThemeCard |  | ||||||
|                     path="/learningPath/search" |  | ||||||
|                     :is-absolute-path="true" |  | ||||||
|                     :title="t('searchAllLearningPathsTitle')" |  | ||||||
|                     :description="t('searchAllLearningPathsDescription')" |  | ||||||
|                     icon="mdi-magnify" |  | ||||||
|                     class="fill-height" |  | ||||||
|                 /> |  | ||||||
|             </v-col> |  | ||||||
|         </v-row> |         </v-row> | ||||||
|     </v-container> |     </v-container> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped> | ||||||
|  |     .grey-bg-card { | ||||||
|  |         background-color: #f6faf2; | ||||||
|  |         border: 2px solid #0e6942; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  |  | ||||||
							
								
								
									
										63
									
								
								frontend/src/components/ButtonWithConfirmation.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/src/components/ButtonWithConfirmation.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,63 @@ | ||||||
|  | <script setup lang="ts"> | ||||||
|  |     import { useI18n } from "vue-i18n"; | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ | ||||||
|  |         text: string; | ||||||
|  |         prependIcon?: string; | ||||||
|  |         appendIcon?: string; | ||||||
|  |         confirmQueryText: string; | ||||||
|  |         variant?: "flat" | "text" | "elevated" | "tonal" | "outlined" | "plain" | undefined; | ||||||
|  |         color?: string; | ||||||
|  |         disabled?: boolean; | ||||||
|  |     }>(); | ||||||
|  | 
 | ||||||
|  |     const emit = defineEmits<{ (e: "confirm"): void }>(); | ||||||
|  | 
 | ||||||
|  |     const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  |     function confirm(): void { | ||||||
|  |         emit("confirm"); | ||||||
|  |     } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <v-dialog max-width="500"> | ||||||
|  |         <template v-slot:activator="{ props: activatorProps }"> | ||||||
|  |             <v-btn | ||||||
|  |                 v-bind="activatorProps" | ||||||
|  |                 :text="props.text" | ||||||
|  |                 :prependIcon="props.prependIcon" | ||||||
|  |                 :appendIcon="props.appendIcon" | ||||||
|  |                 :variant="props.variant" | ||||||
|  |                 :color="color" | ||||||
|  |                 :disabled="props.disabled" | ||||||
|  |             ></v-btn> | ||||||
|  |         </template> | ||||||
|  | 
 | ||||||
|  |         <template v-slot:default="{ isActive }"> | ||||||
|  |             <v-card :title="t('confirmDialogTitle')"> | ||||||
|  |                 <v-card-text> | ||||||
|  |                     {{ props.confirmQueryText }} | ||||||
|  |                 </v-card-text> | ||||||
|  | 
 | ||||||
|  |                 <v-card-actions> | ||||||
|  |                     <v-spacer></v-spacer> | ||||||
|  | 
 | ||||||
|  |                     <v-btn | ||||||
|  |                         :text="t('yes')" | ||||||
|  |                         @click=" | ||||||
|  |                             confirm(); | ||||||
|  |                             isActive.value = false; | ||||||
|  |                         " | ||||||
|  |                     ></v-btn> | ||||||
|  |                     <v-btn | ||||||
|  |                         :text="t('cancel')" | ||||||
|  |                         @click="isActive.value = false" | ||||||
|  |                     ></v-btn> | ||||||
|  |                 </v-card-actions> | ||||||
|  |             </v-card> | ||||||
|  |         </template> | ||||||
|  |     </v-dialog> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style scoped></style> | ||||||
|  | @ -31,4 +31,9 @@ | ||||||
|     ></v-text-field> |     ></v-text-field> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped> | ||||||
|  |     .search-field { | ||||||
|  |         width: 25%; | ||||||
|  |         min-width: 300px; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  |  | ||||||
|  | @ -53,9 +53,9 @@ | ||||||
|         white-space: normal; |         white-space: normal; | ||||||
|     } |     } | ||||||
|     .results-grid { |     .results-grid { | ||||||
|         margin: 20px; |         margin: 20px auto; | ||||||
|         display: flex; |         display: flex; | ||||||
|         align-items: stretch; |         justify-content: center; | ||||||
|         gap: 20px; |         gap: 20px; | ||||||
|         flex-wrap: wrap; |         flex-wrap: wrap; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -7,13 +7,17 @@ | ||||||
| 
 | 
 | ||||||
|     // Import assets |     // Import assets | ||||||
|     import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; |     import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; | ||||||
|  |     import { useLocale } from "vuetify"; | ||||||
| 
 | 
 | ||||||
|     const { t, locale } = useI18n(); |     const { t, locale } = useI18n(); | ||||||
|  |     const { current: vuetifyLocale } = useLocale(); | ||||||
| 
 | 
 | ||||||
|     const role = auth.authState.activeRole; |     const role = auth.authState.activeRole; | ||||||
|     const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable |     const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable | ||||||
| 
 | 
 | ||||||
|     const name: string = auth.authState.user!.profile.name!; |     const name: string = auth.authState.user!.profile.name!; | ||||||
|  |     const username = auth.authState.user!.profile.preferred_username!; | ||||||
|  |     const email = auth.authState.user!.profile.email; | ||||||
|     const initials: string = name |     const initials: string = name | ||||||
|         .split(" ") |         .split(" ") | ||||||
|         .map((n) => n[0]) |         .map((n) => n[0]) | ||||||
|  | @ -30,6 +34,7 @@ | ||||||
|     // Logic to change the language of the website to the selected language |     // Logic to change the language of the website to the selected language | ||||||
|     function changeLanguage(langCode: string): void { |     function changeLanguage(langCode: string): void { | ||||||
|         locale.value = langCode; |         locale.value = langCode; | ||||||
|  |         vuetifyLocale.value = langCode; | ||||||
|         localStorage.setItem("user-lang", langCode); |         localStorage.setItem("user-lang", langCode); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -90,7 +95,11 @@ | ||||||
|             <!--            >--> |             <!--            >--> | ||||||
|             <!--                {{ t("discussions") }}--> |             <!--                {{ t("discussions") }}--> | ||||||
|             <!--            </v-btn>--> |             <!--            </v-btn>--> | ||||||
|             <v-menu open-on-hover> |         </v-toolbar-items> | ||||||
|  |         <v-menu | ||||||
|  |             open-on-hover | ||||||
|  |             open-on-click | ||||||
|  |         > | ||||||
|             <template v-slot:activator="{ props }"> |             <template v-slot:activator="{ props }"> | ||||||
|                 <v-btn |                 <v-btn | ||||||
|                     v-bind="props" |                     v-bind="props" | ||||||
|  | @ -114,7 +123,6 @@ | ||||||
|                 </v-list-item> |                 </v-list-item> | ||||||
|             </v-list> |             </v-list> | ||||||
|         </v-menu> |         </v-menu> | ||||||
|         </v-toolbar-items> |  | ||||||
|         <v-spacer></v-spacer> |         <v-spacer></v-spacer> | ||||||
|         <v-dialog max-width="500"> |         <v-dialog max-width="500"> | ||||||
|             <template v-slot:activator="{ props: activatorProps }"> |             <template v-slot:activator="{ props: activatorProps }"> | ||||||
|  | @ -158,12 +166,48 @@ | ||||||
|                 </v-card> |                 </v-card> | ||||||
|             </template> |             </template> | ||||||
|         </v-dialog> |         </v-dialog> | ||||||
|         <v-avatar |         <v-menu min-width="200px"> | ||||||
|             size="large" |             <template v-slot:activator="{ props }"> | ||||||
|             color="#0e6942" |                 <v-btn | ||||||
|             class="user-button" |                     icon | ||||||
|             >{{ initials }}</v-avatar |                     v-bind="props" | ||||||
|                 > |                 > | ||||||
|  |                     <v-avatar | ||||||
|  |                         color="#0e6942" | ||||||
|  |                         size="large" | ||||||
|  |                         class="user-button" | ||||||
|  |                     > | ||||||
|  |                         <span>{{ initials }}</span> | ||||||
|  |                     </v-avatar> | ||||||
|  |                 </v-btn> | ||||||
|  |             </template> | ||||||
|  |             <v-card> | ||||||
|  |                 <v-card-text> | ||||||
|  |                     <div class="mx-auto text-center"> | ||||||
|  |                         <v-avatar | ||||||
|  |                             color="#0e6942" | ||||||
|  |                             size="large" | ||||||
|  |                             class="user-button mb-3" | ||||||
|  |                         > | ||||||
|  |                             <span>{{ initials }}</span> | ||||||
|  |                         </v-avatar> | ||||||
|  |                         <h3>{{ name }}</h3> | ||||||
|  |                         <p class="text-caption mt-1">{{ username }}</p> | ||||||
|  |                         <p class="text-caption mt-1">{{ email }}</p> | ||||||
|  |                         <v-divider class="my-3"></v-divider> | ||||||
|  |                         <v-btn | ||||||
|  |                             variant="text" | ||||||
|  |                             rounded | ||||||
|  |                             append-icon="mdi-logout" | ||||||
|  |                             @click="performLogout" | ||||||
|  |                             to="/login" | ||||||
|  |                             >{{ t("logout") }}</v-btn | ||||||
|  |                         > | ||||||
|  |                         <v-divider class="my-3"></v-divider> | ||||||
|  |                     </div> | ||||||
|  |                 </v-card-text> | ||||||
|  |             </v-card> | ||||||
|  |         </v-menu> | ||||||
|     </v-app-bar> |     </v-app-bar> | ||||||
|     <v-navigation-drawer |     <v-navigation-drawer | ||||||
|         v-model="drawer" |         v-model="drawer" | ||||||
|  | @ -248,6 +292,12 @@ | ||||||
|         text-transform: none; |         text-transform: none; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     .translate-button { | ||||||
|  |         z-index: 1; | ||||||
|  |         position: relative; | ||||||
|  |         margin-left: 10px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     @media (max-width: 700px) { |     @media (max-width: 700px) { | ||||||
|         .menu { |         .menu { | ||||||
|             display: none; |             display: none; | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show more
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana