Merge pull request #259 from SELab-2/feat/endpoints-in-backend-om-eigen-leerpaden-en-leerobjecten-toe-te-voegen-aan-de-databank-#248
feat: Eigen leerpaden en leerobjecten
This commit is contained in:
		
						commit
						7a76efcd9b
					
				
					 53 changed files with 3280 additions and 1168 deletions
				
			
		|  | @ -29,6 +29,7 @@ | |||
|         "cross-env": "^7.0.3", | ||||
|         "dotenv": "^16.4.7", | ||||
|         "express": "^5.0.1", | ||||
|         "express-fileupload": "^1.5.1", | ||||
|         "express-jwt": "^8.5.1", | ||||
|         "gift-pegjs": "^1.0.2", | ||||
|         "isomorphic-dompurify": "^2.22.0", | ||||
|  | @ -37,9 +38,11 @@ | |||
|         "jwks-rsa": "^3.1.0", | ||||
|         "loki-logger-ts": "^1.0.2", | ||||
|         "marked": "^15.0.7", | ||||
|         "mime-types": "^3.0.1", | ||||
|         "nanoid": "^5.1.5", | ||||
|         "response-time": "^2.3.3", | ||||
|         "swagger-ui-express": "^5.0.1", | ||||
|         "unzipper": "^0.12.3", | ||||
|         "uuid": "^11.1.0", | ||||
|         "winston": "^3.17.0", | ||||
|         "winston-loki": "^6.1.3" | ||||
|  | @ -48,10 +51,13 @@ | |||
|         "@mikro-orm/cli": "6.4.12", | ||||
|         "@types/cors": "^2.8.17", | ||||
|         "@types/express": "^5.0.0", | ||||
|         "@types/express-fileupload": "^1.5.1", | ||||
|         "@types/js-yaml": "^4.0.9", | ||||
|         "@types/mime-types": "^2.1.4", | ||||
|         "@types/node": "^22.13.4", | ||||
|         "@types/response-time": "^2.3.8", | ||||
|         "@types/swagger-ui-express": "^4.1.8", | ||||
|         "@types/unzipper": "^0.10.11", | ||||
|         "globals": "^15.15.0", | ||||
|         "ts-node": "^10.9.2", | ||||
|         "tsx": "^4.19.3", | ||||
|  |  | |||
|  | @ -7,6 +7,9 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; | |||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||
| import { envVars, getEnvVar } from '../util/envVars.js'; | ||||
| 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 { | ||||
|     if (!req.params.hruid) { | ||||
|  | @ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde | |||
| } | ||||
| 
 | ||||
| function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { | ||||
|     if (!req.query.hruid) { | ||||
|         throw new BadRequestException('HRUID is required.'); | ||||
|     } | ||||
|     const { hruid, language } = req.params; | ||||
|     requireFields({ hruid }); | ||||
| 
 | ||||
|     return { | ||||
|         hruid: req.params.hruid, | ||||
|         language: (req.query.language as Language) || FALLBACK_LANG, | ||||
|         hruid, | ||||
|         language: (language as Language) || FALLBACK_LANG, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { | ||||
|     const learningPathId = getLearningPathIdentifierFromRequest(req); | ||||
|     const full = req.query.full; | ||||
|     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); | ||||
| 
 | ||||
|     let learningObjects: FilteredLearningObject[] | string[]; | ||||
|     if (full) { | ||||
|         learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); | ||||
|         res.json(learningObjects); | ||||
|     } else { | ||||
|         learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); | ||||
|     } | ||||
|         // Else he/she wants all learning objects on the path specified by the request parameters.
 | ||||
|         const learningPathId = getLearningPathIdentifierFromRequest(req); | ||||
|         const full = req.query.full; | ||||
| 
 | ||||
|     res.json({ learningObjects: learningObjects }); | ||||
|         let learningObjects: FilteredLearningObject[] | string[]; | ||||
|         if (full) { | ||||
|             learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); | ||||
|         } else { | ||||
|             learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); | ||||
|         } | ||||
| 
 | ||||
|         res.json({ learningObjects: learningObjects }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export async function getLearningObject(req: Request, res: Response): Promise<void> { | ||||
|  | @ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise<void> | |||
|     } | ||||
|     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,51 +7,103 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; | |||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||
| import { Group } from '../entities/assignments/group.entity.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. | ||||
|  */ | ||||
| export async function getLearningPaths(req: Request, res: Response): Promise<void> { | ||||
|     const hruids = req.query.hruid; | ||||
|     const themeKey = req.query.theme as string; | ||||
|     const searchQuery = req.query.search as string; | ||||
|     const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|     const forGroupNo = req.query.forGroup as string; | ||||
|     const assignmentNo = req.query.assignmentNo as string; | ||||
|     const classId = req.query.classId as string; | ||||
| 
 | ||||
|     let forGroup: Group | undefined; | ||||
| 
 | ||||
|     if (forGroupNo) { | ||||
|         if (!assignmentNo || !classId) { | ||||
|             throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); | ||||
|         } | ||||
|         const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); | ||||
|         if (assignment) { | ||||
|             forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let hruidList; | ||||
| 
 | ||||
|     if (hruids) { | ||||
|         hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; | ||||
|     } else if (themeKey) { | ||||
|         const theme = themes.find((t) => t.title === themeKey); | ||||
|         if (theme) { | ||||
|             hruidList = theme.hruids; | ||||
|         } else { | ||||
|             throw new NotFoundException(`Theme "${themeKey}" not found.`); | ||||
|         } | ||||
|     } else if (searchQuery) { | ||||
|         const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); | ||||
|         res.json(searchResults); | ||||
|         return; | ||||
|     const admin = req.query.admin; | ||||
|     if (admin) { | ||||
|         const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); | ||||
|         res.json(paths); | ||||
|     } else { | ||||
|         hruidList = themes.flatMap((theme) => theme.hruids); | ||||
|     } | ||||
|         const hruids = req.query.hruid; | ||||
|         const themeKey = req.query.theme as string; | ||||
|         const searchQuery = req.query.search as string; | ||||
|         const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|     const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); | ||||
|     res.json(learningPaths.data); | ||||
|         const forGroupNo = req.query.forGroup as string; | ||||
|         const assignmentNo = req.query.assignmentNo as string; | ||||
|         const classId = req.query.classId as string; | ||||
| 
 | ||||
|         let forGroup: Group | undefined; | ||||
| 
 | ||||
|         if (forGroupNo) { | ||||
|             if (!assignmentNo || !classId) { | ||||
|                 throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); | ||||
|             } | ||||
|             const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); | ||||
|             if (assignment) { | ||||
|                 forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         let hruidList; | ||||
| 
 | ||||
|         if (hruids) { | ||||
|             hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; | ||||
|         } else if (themeKey) { | ||||
|             const theme = themes.find((t) => t.title === themeKey); | ||||
|             if (theme) { | ||||
|                 hruidList = theme.hruids; | ||||
|             } else { | ||||
|                 throw new NotFoundException(`Theme "${themeKey}" not found.`); | ||||
|             } | ||||
|         } else if (searchQuery) { | ||||
|             const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); | ||||
|             res.json(searchResults); | ||||
|             return; | ||||
|         } else { | ||||
|             hruidList = themes.flatMap((theme) => theme.hruids); | ||||
|         } | ||||
| 
 | ||||
|         const learningPaths = await learningPathService.fetchLearningPaths( | ||||
|             hruidList, | ||||
|             language as Language, | ||||
|             `HRUIDs: ${hruidList.join(', ')}`, | ||||
|             forGroup | ||||
|         ); | ||||
|         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.'); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj | |||
|                 version: identifier.version, | ||||
|             }, | ||||
|             { | ||||
|                 populate: ['keywords'], | ||||
|                 populate: ['keywords', 'admins'], | ||||
|             } | ||||
|         ); | ||||
|     } | ||||
|  | @ -31,4 +31,23 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj | |||
|             } | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public async findAllByAdmin(adminUsername: string): Promise<LearningObject[]> { | ||||
|         return this.find( | ||||
|             { | ||||
|                 admins: { | ||||
|                     username: adminUsername, | ||||
|                 }, | ||||
|             }, | ||||
|             { 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 { RequiredEntityData } from '@mikro-orm/core'; | ||||
| 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> { | ||||
|     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, | ||||
|                 $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); | ||||
|     } | ||||
| 
 | ||||
|     public async saveLearningPathNodesAndTransitions( | ||||
|         path: LearningPath, | ||||
|         nodes: LearningPathNode[], | ||||
|         transitions: LearningPathTransition[], | ||||
|         options?: { preventOverwrite?: boolean } | ||||
|     ): Promise<void> { | ||||
|         if (options?.preventOverwrite && (await this.findOne(path))) { | ||||
|             throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); | ||||
|     /** | ||||
|      * Deletes the learning path with the given hruid and language. | ||||
|      * @returns the deleted learning path or null if it was not found. | ||||
|      */ | ||||
|     public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|         const path = await this.findByHruidAndLanguage(hruid, language); | ||||
|         if (path) { | ||||
|             await this.em.removeAndFlush(path); | ||||
|         } | ||||
|         const em = this.getEntityManager(); | ||||
|         await em.persistAndFlush(path); | ||||
|         await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); | ||||
|         await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); | ||||
|         return path; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ export class Attachment { | |||
|     @ManyToOne({ | ||||
|         entity: () => LearningObject, | ||||
|         primary: true, | ||||
|         deleteRule: 'cascade', | ||||
|     }) | ||||
|     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 { Teacher } from '../users/teacher.entity.js'; | ||||
| import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | ||||
|  | @ -28,7 +28,7 @@ export class LearningObject { | |||
|     @ManyToMany({ | ||||
|         entity: () => Teacher, | ||||
|     }) | ||||
|     admins!: Teacher[]; | ||||
|     admins: Collection<Teacher> = new Collection<Teacher>(this); | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
|  | @ -84,7 +84,7 @@ export class LearningObject { | |||
|         entity: () => Attachment, | ||||
|         mappedBy: 'learningObject', | ||||
|     }) | ||||
|     attachments: Attachment[] = []; | ||||
|     attachments: Collection<Attachment> = new Collection<Attachment>(this); | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     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 { LearningPathTransition } from './learning-path-transition.entity.js'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
|  | @ -26,7 +26,7 @@ export class LearningPathNode { | |||
|     @Property({ type: 'bool' }) | ||||
|     startNode!: boolean; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) | ||||
|     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] }) | ||||
|     transitions!: Collection<LearningPathTransition>; | ||||
| 
 | ||||
|     @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 { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | ||||
| import { LearningPathNode } from './learning-path-node.entity.js'; | ||||
|  | @ -24,6 +24,6 @@ export class LearningPath { | |||
|     @Property({ type: 'blob', nullable: true }) | ||||
|     image: Buffer | null = null; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) | ||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] }) | ||||
|     nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this); | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
| }); | ||||
|  | @ -1,8 +1,17 @@ | |||
| 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 questionRoutes from './questions.js'; | ||||
| import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.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(); | ||||
| 
 | ||||
|  | @ -18,12 +27,20 @@ const router = express.Router(); | |||
| // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
 | ||||
| router.get('/', authenticatedOnly, getAllLearningObjects); | ||||
| 
 | ||||
| router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject); | ||||
| 
 | ||||
| // Parameter: hruid of learning object
 | ||||
| // Query: language
 | ||||
| // Route to fetch data of one learning object based on its hruid
 | ||||
| // Example: http://localhost:3000/learningObject/un_ai7
 | ||||
| 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/:version/questions', questionRoutes); | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import express from 'express'; | ||||
| import { getLearningPaths } from '../controllers/learning-paths.js'; | ||||
| import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.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(); | ||||
| 
 | ||||
|  | @ -24,5 +25,9 @@ const router = express.Router(); | |||
| // Example: http://localhost:3000/learningPath?theme=kiks
 | ||||
| 
 | ||||
| router.get('/', authenticatedOnly, getLearningPaths); | ||||
| router.post('/', teachersOnly, postLearningPath); | ||||
| 
 | ||||
| router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath); | ||||
| router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = { | |||
|         ); | ||||
|         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; | ||||
|  |  | |||
|  | @ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { | |||
| 
 | ||||
|         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; | ||||
|  |  | |||
|  | @ -20,4 +20,9 @@ export interface LearningObjectProvider { | |||
|      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||
|      */ | ||||
|     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 databaseLearningObjectProvider from './database-learning-object-provider.js'; | ||||
| 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 { | ||||
|     if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { | ||||
|  | @ -42,6 +47,66 @@ const learningObjectService = { | |||
|     async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> { | ||||
|         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; | ||||
|  |  | |||
|  | @ -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(); | ||||
| } | ||||
|  | @ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language'; | |||
| import { Group } from '../../entities/assignments/group.entity'; | ||||
| import { Collection } from '@mikro-orm/core'; | ||||
| 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 | ||||
|  | @ -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>; | ||||
| } | ||||
|  | @ -102,7 +110,15 @@ async function convertNode( | |||
|                 !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.
 | ||||
|         ) | ||||
|         .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 { | ||||
|         _id: learningObject.uuid, | ||||
|         language: learningObject.language, | ||||
|  | @ -164,6 +180,7 @@ function convertTransition( | |||
|         return { | ||||
|             _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
 | ||||
|             default: false, // We don't work with default transitions but retain this for backwards compatibility.
 | ||||
|             condition: transition.condition, | ||||
|             next: { | ||||
|                 _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
 | ||||
|                 hruid: transition.next.learningObjectHruid, | ||||
|  | @ -198,6 +215,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. | ||||
|      */ | ||||
|  |  | |||
|  | @ -74,6 +74,10 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
| 
 | ||||
|         return searchResults ?? []; | ||||
|     }, | ||||
| 
 | ||||
|     async getLearningPathsAdministratedBy(_adminUsername: string) { | ||||
|         return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
 | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default dwengoApiLearningPathProvider; | ||||
|  |  | |||
|  | @ -15,4 +15,9 @@ export interface LearningPathProvider { | |||
|      * Search learning paths in the data source using the given search string. | ||||
|      */ | ||||
|     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 databaseLearningPathProvider from './database-learning-path-provider.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 { Group } from '../../entities/assignments/group.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 { mapToTeacher } from '../../interfaces/teacher.js'; | ||||
| 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 allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | ||||
|  | @ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | |||
|         const fromNode = nodes.find( | ||||
|             (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version | ||||
|         )!; | ||||
|         const transitions = nodeDto.transitions | ||||
|             .map((transDto, i) => { | ||||
|                 const toNode = nodes.find( | ||||
|                     (it) => | ||||
|                         it.learningObjectHruid === transDto.next.hruid && | ||||
|                         it.language === transDto.next.language && | ||||
|                         it.version === transDto.next.version | ||||
|                 ); | ||||
|         const transitions = nodeDto.transitions.map((transDto, i) => { | ||||
|             const toNode = nodes.find( | ||||
|                 (it) => | ||||
|                     it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version | ||||
|             ); | ||||
| 
 | ||||
|                 if (toNode) { | ||||
|                     return repo.createTransition({ | ||||
|                         transitionNumber: i, | ||||
|                         node: fromNode, | ||||
|                         next: toNode, | ||||
|                         condition: transDto.condition ?? 'true', | ||||
|                     }); | ||||
|                 } | ||||
|                 return undefined; | ||||
|             }) | ||||
|             .filter((it) => it) | ||||
|             .map((it) => it!); | ||||
|             if (toNode) { | ||||
|                 return repo.createTransition({ | ||||
|                     transitionNumber: i, | ||||
|                     node: fromNode, | ||||
|                     next: toNode, | ||||
|                     condition: transDto.condition ?? 'true', | ||||
|                 }); | ||||
|             } | ||||
|             throw new BadRequestException( | ||||
|                 `Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.` | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         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. | ||||
|      */ | ||||
|  | @ -119,11 +127,67 @@ const learningPathService = { | |||
|      * Add a new learning path to the database. | ||||
|      * @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. | ||||
|      * @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 userContentPrefix = getEnvVar(envVars.UserContentPrefix); | ||||
|         if (!dto.hruid.startsWith(userContentPrefix)) { | ||||
|             dto.hruid = userContentPrefix + dto.hruid; | ||||
|         } | ||||
| 
 | ||||
|         const path = mapToLearningPath(dto, admins); | ||||
|         await repo.save(path, { preventOverwrite: true }); | ||||
| 
 | ||||
|         // 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 }); | ||||
|         } 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); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,8 +25,8 @@ export interface LearningObjectNode { | |||
|     language: Language; | ||||
|     start_node?: boolean; | ||||
|     transitions: Transition[]; | ||||
|     created_at: string; | ||||
|     updatedAt: string; | ||||
|     created_at?: string; | ||||
|     updatedAt?: string; | ||||
|     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[]; | ||||
|     content_type: string; // Markdown, image, etc.
 | ||||
|     content_location?: string; | ||||
|     copyright?: string; | ||||
|     license?: string; | ||||
|     skos_concepts?: string[]; | ||||
|     return_value?: ReturnValue; | ||||
| } | ||||
|  |  | |||
|  | @ -3,22 +3,28 @@ FROM node:22 AS build-stage | |||
| # install simple http server for serving static content | ||||
| RUN npm install -g http-server | ||||
| 
 | ||||
| WORKDIR /app | ||||
| WORKDIR /app/dwengo | ||||
| 
 | ||||
| # Install dependencies | ||||
| 
 | ||||
| COPY package*.json ./ | ||||
| COPY ./frontend/package.json ./frontend/ | ||||
| # Frontend depends on common | ||||
| COPY common/package.json ./common/ | ||||
| 
 | ||||
| RUN npm install --silent | ||||
| 
 | ||||
| # Build the frontend | ||||
| 
 | ||||
| # Root tsconfig.json | ||||
| COPY tsconfig.json ./ | ||||
| COPY assets ./assets/ | ||||
| COPY tsconfig.json tsconfig.build.json ./ | ||||
| 
 | ||||
| WORKDIR /app/frontend | ||||
| COPY assets ./assets | ||||
| COPY common ./common | ||||
| 
 | ||||
| RUN npm run build --workspace=common | ||||
| 
 | ||||
| WORKDIR /app/dwengo/frontend | ||||
| 
 | ||||
| COPY frontend ./ | ||||
| 
 | ||||
|  | @ -28,8 +34,8 @@ FROM nginx:stable AS production-stage | |||
| 
 | ||||
| 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/frontend/dist /usr/share/nginx/html | ||||
| COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets | ||||
| COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html | ||||
| 
 | ||||
| EXPOSE 8080 | ||||
| 
 | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ | |||
|         "@tanstack/vue-query": "^5.69.0", | ||||
|         "@vueuse/core": "^13.1.0", | ||||
|         "axios": "^1.8.2", | ||||
|         "json-editor-vue": "^0.18.1", | ||||
|         "oidc-client-ts": "^3.1.0", | ||||
|         "rollup": "^4.40.0", | ||||
|         "uuid": "^11.1.0", | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ | |||
|     import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; | ||||
|     import { useThemeQuery } from "@/queries/themes.ts"; | ||||
|     import type { Theme } from "@/data-objects/theme.ts"; | ||||
|     import authService from "@/services/auth/auth-service"; | ||||
| 
 | ||||
|     const props = defineProps({ | ||||
|         selectedTheme: { type: String, required: true }, | ||||
|  | @ -33,6 +34,8 @@ | |||
|             cards.value = themes; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     const isTeacher = computed(() => authService.authState.activeRole === "teacher"); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -73,6 +76,23 @@ | |||
|                     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-for="card in cards" | ||||
|                 :key="card.key" | ||||
|  |  | |||
							
								
								
									
										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> | ||||
|  | @ -7,8 +7,10 @@ | |||
| 
 | ||||
|     // Import assets | ||||
|     import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; | ||||
|     import { useLocale } from "vuetify"; | ||||
| 
 | ||||
|     const { t, locale } = useI18n(); | ||||
|     const { current: vuetifyLocale } = useLocale(); | ||||
| 
 | ||||
|     const role = auth.authState.activeRole; | ||||
|     const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable | ||||
|  | @ -32,6 +34,7 @@ | |||
|     // Logic to change the language of the website to the selected language | ||||
|     function changeLanguage(langCode: string): void { | ||||
|         locale.value = langCode; | ||||
|         vuetifyLocale.value = langCode; | ||||
|         localStorage.setItem("user-lang", langCode); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ | |||
|     <div v-if="isError"> | ||||
|         <v-empty-state | ||||
|             icon="mdi-alert-circle-outline" | ||||
|             :text="errorMessage" | ||||
|             :text="t(errorMessage)" | ||||
|             :title="t('error_title')" | ||||
|         ></v-empty-state> | ||||
|     </div> | ||||
|  |  | |||
|  | @ -37,6 +37,33 @@ export abstract class BaseController { | |||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sends a POST-request with a form-data body with the given file. | ||||
|      * | ||||
|      * @param path Relative path in the api to send the request to. | ||||
|      * @param formFieldName The name of the form field in which the file should be. | ||||
|      * @param file The file to upload. | ||||
|      * @param queryParams The query parameters. | ||||
|      * @returns The response the POST request generated. | ||||
|      */ | ||||
|     protected async postFile<T>( | ||||
|         path: string, | ||||
|         formFieldName: string, | ||||
|         file: File, | ||||
|         queryParams?: QueryParams, | ||||
|     ): Promise<T> { | ||||
|         const formData = new FormData(); | ||||
|         formData.append(formFieldName, file); | ||||
|         const response = await apiClient.post<T>(this.absolutePathFor(path), formData, { | ||||
|             params: queryParams, | ||||
|             headers: { | ||||
|                 "Content-Type": "multipart/form-data", | ||||
|             }, | ||||
|         }); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|         return response.data; | ||||
|     } | ||||
| 
 | ||||
|     protected async delete<T>(path: string, queryParams?: QueryParams): Promise<T> { | ||||
|         const response = await apiClient.delete<T>(this.absolutePathFor(path), { params: queryParams }); | ||||
|         BaseController.assertSuccessResponse(response); | ||||
|  |  | |||
|  | @ -14,4 +14,16 @@ export class LearningObjectController extends BaseController { | |||
|     async getHTML(hruid: string, language: Language, version: number): Promise<Document> { | ||||
|         return this.get<Document>(`/${hruid}/html`, { language, version }, "document"); | ||||
|     } | ||||
| 
 | ||||
|     async getAllAdministratedBy(admin: string): Promise<LearningObject[]> { | ||||
|         return this.get<LearningObject[]>("/", { admin }); | ||||
|     } | ||||
| 
 | ||||
|     async upload(learningObjectZip: File): Promise<LearningObject> { | ||||
|         return this.postFile<LearningObject>("/", "learningObject", learningObjectZip); | ||||
|     } | ||||
| 
 | ||||
|     async deleteLearningObject(hruid: string, language: Language, version: number): Promise<LearningObject> { | ||||
|         return this.delete<LearningObject>(`/${hruid}`, { language, version }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| import { BaseController } from "@/controllers/base-controller.ts"; | ||||
| import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { single } from "@/utils/response-assertions.ts"; | ||||
| import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; | ||||
| import { LearningPath } from "@/data-objects/learning-paths/learning-path"; | ||||
| import { NotFoundException } from "@/exception/not-found-exception"; | ||||
| import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| 
 | ||||
| export class LearningPathController extends BaseController { | ||||
|     constructor() { | ||||
|  | @ -24,7 +24,10 @@ export class LearningPathController extends BaseController { | |||
|             assignmentNo: forGroup?.assignmentNo, | ||||
|             classId: forGroup?.classId, | ||||
|         }); | ||||
|         return LearningPath.fromDTO(single(dtos)); | ||||
|         if (dtos.length === 0) { | ||||
|             throw new NotFoundException("learningPathNotFound"); | ||||
|         } | ||||
|         return LearningPath.fromDTO(dtos[0]); | ||||
|     } | ||||
|     async getAllByThemeAndLanguage(theme: string, language: Language): Promise<LearningPath[]> { | ||||
|         const dtos = await this.get<LearningPathDTO[]>("/", { theme, language }); | ||||
|  | @ -36,4 +39,20 @@ export class LearningPathController extends BaseController { | |||
|         const dtos = await this.get<LearningPathDTO[]>("/", query); | ||||
|         return dtos.map((dto) => LearningPath.fromDTO(dto)); | ||||
|     } | ||||
| 
 | ||||
|     async getAllByAdminRaw(admin: string): Promise<LearningPathDTO[]> { | ||||
|         return await this.get<LearningPathDTO[]>("/", { admin }); | ||||
|     } | ||||
| 
 | ||||
|     async postLearningPath(learningPath: Partial<LearningPathDTO>): Promise<LearningPathDTO> { | ||||
|         return await this.post<LearningPathDTO>("/", learningPath); | ||||
|     } | ||||
| 
 | ||||
|     async putLearningPath(learningPath: Partial<LearningPathDTO>): Promise<LearningPathDTO> { | ||||
|         return await this.put<LearningPathDTO>(`/${learningPath.hruid}/${learningPath.language}`, learningPath); | ||||
|     } | ||||
| 
 | ||||
|     async deleteLearningPath(hruid: string, language: string): Promise<LearningPathDTO> { | ||||
|         return await this.delete<LearningPathDTO>(`/${hruid}/${language}`); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { LearningObjectNode as LearningPathNodeDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| 
 | ||||
| export class LearningPathNode { | ||||
|     public readonly learningobjectHruid: string; | ||||
|  | @ -14,7 +14,7 @@ export class LearningPathNode { | |||
|         learningobjectHruid: string; | ||||
|         version: number; | ||||
|         language: Language; | ||||
|         transitions: { next: LearningPathNode; default: boolean }[]; | ||||
|         transitions: { next: LearningPathNode; default?: boolean }[]; | ||||
|         createdAt: Date; | ||||
|         updatedAt: Date; | ||||
|         done?: boolean; | ||||
|  | @ -22,7 +22,7 @@ export class LearningPathNode { | |||
|         this.learningobjectHruid = options.learningobjectHruid; | ||||
|         this.version = options.version; | ||||
|         this.language = options.language; | ||||
|         this.transitions = options.transitions; | ||||
|         this.transitions = options.transitions.map((it) => ({ next: it.next, default: it.default ?? false })); | ||||
|         this.createdAt = options.createdAt; | ||||
|         this.updatedAt = options.updatedAt; | ||||
|         this.done = options.done || false; | ||||
|  | @ -50,8 +50,8 @@ export class LearningPathNode { | |||
|                     return undefined; | ||||
|                 }) | ||||
|                 .filter((it) => it !== undefined), | ||||
|             createdAt: new Date(dto.created_at), | ||||
|             updatedAt: new Date(dto.updatedAt), | ||||
|             createdAt: dto.created_at ? new Date(dto.created_at) : new Date(), | ||||
|             updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : new Date(), | ||||
|             done: dto.done, | ||||
|         }); | ||||
|     } | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; | ||||
| import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; | ||||
| import type { LearningObjectNode, LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| 
 | ||||
| export interface LearningPathNodeDTO { | ||||
|     _id: string; | ||||
|  | @ -77,20 +77,26 @@ export class LearningPath { | |||
|             hruid: dto.hruid, | ||||
|             title: dto.title, | ||||
|             description: dto.description, | ||||
|             amountOfNodes: dto.num_nodes, | ||||
|             amountOfNodesLeft: dto.num_nodes_left, | ||||
|             amountOfNodes: dto.num_nodes ?? dto.nodes.length, | ||||
|             amountOfNodesLeft: dto.num_nodes_left ?? dto.nodes.length, | ||||
|             keywords: dto.keywords.split(" "), | ||||
|             targetAges: { min: dto.min_age, max: dto.max_age }, | ||||
|             targetAges: { | ||||
|                 min: dto.min_age ?? NaN, | ||||
|                 max: dto.max_age ?? NaN, | ||||
|             }, | ||||
|             startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), | ||||
|             image: dto.image, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { | ||||
|     static getStartNode(dto: LearningPathDTO): LearningObjectNode { | ||||
|         const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); | ||||
|         if (startNodeDtos.length < 1) { | ||||
|             // The learning path has no starting node -> use the first node.
 | ||||
|             return dto.nodes[0]; | ||||
|             if (dto.nodes.length > 0) { | ||||
|                 return dto.nodes[0]; | ||||
|             } | ||||
|             throw new Error("emptyLearningPath"); | ||||
|         } // The learning path has 1 or more starting nodes -> use the first start node.
 | ||||
|         return startNodeDtos[0]; | ||||
|     } | ||||
|  |  | |||
|  | @ -135,5 +135,36 @@ | |||
|     "valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein", | ||||
|     "creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut", | ||||
|     "no-assignments": "Derzeit gibt es keine Zuweisungen.", | ||||
|     "deadline": "deadline" | ||||
|     "deadline": "deadline", | ||||
|     "learningObjects": "Lernobjekte", | ||||
|     "learningPaths": "Lernpfade", | ||||
|     "hruid": "HRUID", | ||||
|     "language": "Sprache", | ||||
|     "version": "Version", | ||||
|     "previewFor": "Vorschau für ", | ||||
|     "upload": "Hochladen", | ||||
|     "learningObjectUploadTitle": "Lernobjekt hochladen", | ||||
|     "uploadFailed": "Hochladen fehlgeschlagen", | ||||
|     "invalidZip": "Dies ist keine gültige ZIP-Datei.", | ||||
|     "emptyZip": "Diese ZIP-Datei ist leer.", | ||||
|     "missingMetadata": "Dieses Lernobjekt enthält keine metadata.json-Datei.", | ||||
|     "missingContent": "Dieses Lernobjekt enthält keine content.*-Datei.", | ||||
|     "open": "öffnen", | ||||
|     "editLearningPath": "Lernpfad bearbeiten", | ||||
|     "newLearningPath": "Neuen Lernpfad erstellen", | ||||
|     "saveChanges": "Änderungen speichern", | ||||
|     "newLearningObject": "Lernobjekt hochladen", | ||||
|     "confirmDialogTitle": "Bitte bestätigen", | ||||
|     "learningPathDeleteQuery": "Möchten Sie diesen Lernpfad wirklich löschen?", | ||||
|     "learningObjectDeleteQuery": "Möchten Sie dieses Lernobjekt wirklich löschen?", | ||||
|     "learningPathCantModifyId": "Der HRUID oder die Sprache eines Lernpfads kann nicht geändert werden.", | ||||
|     "error": "Fehler", | ||||
|     "ownLearningContentTitle": "Eigene Lerninhalte", | ||||
|     "ownLearningContentDescription": "Erstellen und verwalten Sie eigene Lernobjekte und Lernpfade. Nur für fortgeschrittene Nutzer.", | ||||
|     "learningPathNotFound": "Dieser Lernpfad konnte nicht gefunden werden.", | ||||
|     "emptyLearningPath": "Dieser Lernpfad enthält keine Lernobjekte.", | ||||
|     "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", | ||||
|     "targetAgesMandatory": "Zielalter müssen angegeben werden.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt" | ||||
| } | ||||
|  |  | |||
|  | @ -135,5 +135,36 @@ | |||
|     "valid-username": "please enter a valid username", | ||||
|     "creationFailed": "creation failed, please try again", | ||||
|     "no-assignments": "There are currently no assignments.", | ||||
|     "deadline": "deadline" | ||||
|     "deadline": "deadline", | ||||
|     "learningObjects": "Learning objects", | ||||
|     "learningPaths": "Learning paths", | ||||
|     "hruid": "HRUID", | ||||
|     "language": "Language", | ||||
|     "version": "Version", | ||||
|     "previewFor": "Preview for ", | ||||
|     "upload": "Upload", | ||||
|     "learningObjectUploadTitle": "Upload a learning object", | ||||
|     "uploadFailed": "Upload failed", | ||||
|     "invalidZip": "This is not a valid zip file.", | ||||
|     "emptyZip": "This zip file is empty", | ||||
|     "missingMetadata": "This learning object is missing a metadata.json file.", | ||||
|     "missingContent": "This learning object is missing a content.* file.", | ||||
|     "open": "open", | ||||
|     "editLearningPath": "Edit learning path", | ||||
|     "newLearningPath": "Create a new learning path", | ||||
|     "saveChanges": "Save changes", | ||||
|     "newLearningObject": "Upload learning object", | ||||
|     "confirmDialogTitle": "Please confirm", | ||||
|     "learningPathDeleteQuery": "Are you sure you want to delete this learning path?", | ||||
|     "learningObjectDeleteQuery": "Are you sure you want to delete this learning object?", | ||||
|     "learningPathCantModifyId": "The HRUID or language of a learning path cannot be modified.", | ||||
|     "error": "Error", | ||||
|     "ownLearningContentTitle": "Own learning content", | ||||
|     "ownLearningContentDescription": "Create and administrate your own learning objects and learning paths. For advanced users only.", | ||||
|     "learningPathNotFound": "This learning path could not be found.", | ||||
|     "emptyLearningPath": "This learning path does not contain any learning objects.", | ||||
|     "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", | ||||
|     "targetAgesMandatory": "Target ages must be specified.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces" | ||||
| } | ||||
|  |  | |||
|  | @ -136,5 +136,36 @@ | |||
|     "valid-username": "veuillez entrer un nom d'utilisateur valide", | ||||
|     "creationFailed": "échec de la création, veuillez réessayer", | ||||
|     "no-assignments": "Il n'y a actuellement aucun travail.", | ||||
|     "deadline": "délai" | ||||
|     "deadline": "délai", | ||||
|     "learningObjects": "Objets d’apprentissage", | ||||
|     "learningPaths": "Parcours d’apprentissage", | ||||
|     "hruid": "HRUID", | ||||
|     "language": "Langue", | ||||
|     "version": "Version", | ||||
|     "previewFor": "Aperçu de ", | ||||
|     "upload": "Téléverser", | ||||
|     "learningObjectUploadTitle": "Téléverser un objet d’apprentissage", | ||||
|     "uploadFailed": "Échec du téléversement", | ||||
|     "invalidZip": "Ce n’est pas un fichier ZIP valide.", | ||||
|     "emptyZip": "Ce fichier ZIP est vide.", | ||||
|     "missingMetadata": "Il manque un fichier metadata.json à cet objet d’apprentissage.", | ||||
|     "missingContent": "Il manque un fichier content.* à cet objet d’apprentissage.", | ||||
|     "open": "ouvrir", | ||||
|     "editLearningPath": "Modifier le parcours", | ||||
|     "newLearningPath": "Créer un nouveau parcours", | ||||
|     "saveChanges": "Enregistrer les modifications", | ||||
|     "newLearningObject": "Téléverser un objet d’apprentissage", | ||||
|     "confirmDialogTitle": "Veuillez confirmer", | ||||
|     "learningPathDeleteQuery": "Voulez-vous vraiment supprimer ce parcours d’apprentissage ?", | ||||
|     "learningObjectDeleteQuery": "Voulez-vous vraiment supprimer cet objet d’apprentissage ?", | ||||
|     "learningPathCantModifyId": "Le HRUID ou la langue d’un parcours ne peuvent pas être modifiés.", | ||||
|     "error": "Erreur", | ||||
|     "ownLearningContentTitle": "Contenu d’apprentissage personnel", | ||||
|     "ownLearningContentDescription": "Créez et gérez vos propres objets et parcours d’apprentissage. Réservé aux utilisateurs avancés.", | ||||
|     "learningPathNotFound": "Ce parcours d'apprentissage est introuvable.", | ||||
|     "emptyLearningPath": "Ce parcours d'apprentissage ne contient aucun objet d'apprentissage.", | ||||
|     "pathContainsNonExistingLearningObjects": "Au moins un des objets d’apprentissage référencés dans ce chemin n’existe pas.", | ||||
|     "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces" | ||||
| } | ||||
|  |  | |||
|  | @ -135,5 +135,36 @@ | |||
|     "valid-username": "voer een geldige gebruikersnaam in", | ||||
|     "creationFailed": "aanmaak mislukt, probeer het opnieuw", | ||||
|     "no-assignments": "Er zijn momenteel geen opdrachten.", | ||||
|     "deadline": "deadline" | ||||
|     "deadline": "deadline", | ||||
|     "learningObjects": "Leerobjecten", | ||||
|     "learningPaths": "Leerpaden", | ||||
|     "hruid": "HRUID", | ||||
|     "language": "Taal", | ||||
|     "version": "Versie", | ||||
|     "previewFor": "Voorbeeld van ", | ||||
|     "upload": "Uploaden", | ||||
|     "learningObjectUploadTitle": "Leerobject uploaden", | ||||
|     "uploadFailed": "Upload mislukt", | ||||
|     "invalidZip": "Dit is geen geldig zipbestand.", | ||||
|     "emptyZip": "Dit zipbestand is leeg.", | ||||
|     "missingMetadata": "Dit leerobject mist een metadata.json-bestand.", | ||||
|     "missingContent": "Dit leerobject mist een content.*-bestand.", | ||||
|     "open": "openen", | ||||
|     "editLearningPath": "Leerpad bewerken", | ||||
|     "newLearningPath": "Nieuw leerpad aanmaken", | ||||
|     "saveChanges": "Wijzigingen opslaan", | ||||
|     "newLearningObject": "Leerobject uploaden", | ||||
|     "confirmDialogTitle": "Bevestig alstublieft", | ||||
|     "learningPathDeleteQuery": "Weet u zeker dat u dit leerpad wilt verwijderen?", | ||||
|     "learningObjectDeleteQuery": "Weet u zeker dat u dit leerobject wilt verwijderen?", | ||||
|     "learningPathCantModifyId": "De HRUID of taal van een leerpad kan niet worden gewijzigd.", | ||||
|     "error": "Fout", | ||||
|     "ownLearningContentTitle": "Eigen leerinhoud", | ||||
|     "ownLearningContentDescription": "Maak en beheer je eigen leerobjecten en leerpads. Alleen voor gevorderde gebruikers.", | ||||
|     "learningPathNotFound": "Dit leerpad kon niet gevonden worden.", | ||||
|     "emptyLearningPath": "Dit leerpad bevat geen leerobjecten.", | ||||
|     "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", | ||||
|     "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", | ||||
|     "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", | ||||
|     "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties" | ||||
| } | ||||
|  |  | |||
|  | @ -7,15 +7,20 @@ import * as components from "vuetify/components"; | |||
| import * as directives from "vuetify/directives"; | ||||
| import i18n from "./i18n/i18n.ts"; | ||||
| 
 | ||||
| // JSON-editor
 | ||||
| import JsonEditorVue from "json-editor-vue"; | ||||
| 
 | ||||
| // Components
 | ||||
| import App from "./App.vue"; | ||||
| import router from "./router"; | ||||
| import { aliases, mdi } from "vuetify/iconsets/mdi"; | ||||
| import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | ||||
| import { de, en, fr, nl } from "vuetify/locale"; | ||||
| 
 | ||||
| const app = createApp(App); | ||||
| 
 | ||||
| app.use(router); | ||||
| app.use(JsonEditorVue, {}); | ||||
| 
 | ||||
| const link = document.createElement("link"); | ||||
| link.rel = "stylesheet"; | ||||
|  | @ -32,6 +37,11 @@ const vuetify = createVuetify({ | |||
|             mdi, | ||||
|         }, | ||||
|     }, | ||||
|     locale: { | ||||
|         locale: i18n.global.locale, | ||||
|         fallback: "en", | ||||
|         messages: { nl, en, de, fr }, | ||||
|     }, | ||||
| }); | ||||
| 
 | ||||
| const queryClient = new QueryClient({ | ||||
|  |  | |||
|  | @ -1,9 +1,16 @@ | |||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { | ||||
|     useMutation, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseMutationReturnType, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { getLearningObjectController } from "@/controllers/controllers.ts"; | ||||
| import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; | ||||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { AxiosError } from "axios"; | ||||
| 
 | ||||
| export const LEARNING_OBJECT_KEY = "learningObject"; | ||||
| const learningObjectController = getLearningObjectController(); | ||||
|  | @ -24,15 +31,15 @@ export function useLearningObjectMetadataQuery( | |||
| } | ||||
| 
 | ||||
| export function useLearningObjectHTMLQuery( | ||||
|     hruid: MaybeRefOrGetter<string>, | ||||
|     language: MaybeRefOrGetter<Language>, | ||||
|     version: MaybeRefOrGetter<number>, | ||||
|     hruid: MaybeRefOrGetter<string | undefined>, | ||||
|     language: MaybeRefOrGetter<Language | undefined>, | ||||
|     version: MaybeRefOrGetter<number | undefined>, | ||||
| ): UseQueryReturnType<Document, Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], | ||||
|         queryFn: async () => { | ||||
|             const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; | ||||
|             return learningObjectController.getHTML(hruidVal, languageVal, versionVal); | ||||
|             return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!); | ||||
|         }, | ||||
|         enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), | ||||
|     }); | ||||
|  | @ -55,3 +62,49 @@ export function useLearningObjectListForPathQuery( | |||
|         enabled: () => Boolean(toValue(learningPath)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useLearningObjectListForAdminQuery( | ||||
|     admin: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<LearningObject[], Error> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_OBJECT_KEY, "forAdmin", admin], | ||||
|         queryFn: async () => { | ||||
|             const adminVal = toValue(admin); | ||||
|             return await learningObjectController.getAllAdministratedBy(adminVal!); | ||||
|         }, | ||||
|         enabled: () => toValue(admin) !== undefined, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useUploadLearningObjectMutation(): UseMutationReturnType< | ||||
|     LearningObject, | ||||
|     AxiosError, | ||||
|     { learningObjectZip: File }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteLearningObjectMutation(): UseMutationReturnType< | ||||
|     LearningObject, | ||||
|     AxiosError, | ||||
|     { hruid: string; language: Language; version: number }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ hruid, language, version }) => | ||||
|             await learningObjectController.deleteLearningObject(hruid, language, version), | ||||
|         onSuccess: async () => { | ||||
|             await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); | ||||
|         }, | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,16 @@ | |||
| import { type MaybeRefOrGetter, toValue } from "vue"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; | ||||
| import { | ||||
|     useMutation, | ||||
|     useQuery, | ||||
|     useQueryClient, | ||||
|     type UseMutationReturnType, | ||||
|     type UseQueryReturnType, | ||||
| } from "@tanstack/vue-query"; | ||||
| import { getLearningPathController } from "@/controllers/controllers"; | ||||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { AxiosError } from "axios"; | ||||
| import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; | ||||
| 
 | ||||
| export const LEARNING_PATH_KEY = "learningPath"; | ||||
| const learningPathController = getLearningPathController(); | ||||
|  | @ -33,6 +41,58 @@ export function useGetAllLearningPathsByThemeAndLanguageQuery( | |||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useGetAllLearningPathsByAdminQuery( | ||||
|     admin: MaybeRefOrGetter<string | undefined>, | ||||
| ): UseQueryReturnType<LearningPathDTO[], AxiosError> { | ||||
|     return useQuery({ | ||||
|         queryKey: [LEARNING_PATH_KEY, "getAllByAdmin", admin], | ||||
|         queryFn: async () => learningPathController.getAllByAdminRaw(toValue(admin)!), | ||||
|         enabled: () => Boolean(toValue(admin)), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function usePostLearningPathMutation(): UseMutationReturnType< | ||||
|     LearningPathDTO, | ||||
|     AxiosError, | ||||
|     { learningPath: Partial<LearningPathDTO> }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ learningPath }) => learningPathController.postLearningPath(learningPath), | ||||
|         onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function usePutLearningPathMutation(): UseMutationReturnType< | ||||
|     LearningPathDTO, | ||||
|     AxiosError, | ||||
|     { learningPath: Partial<LearningPathDTO> }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ learningPath }) => learningPathController.putLearningPath(learningPath), | ||||
|         onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useDeleteLearningPathMutation(): UseMutationReturnType< | ||||
|     LearningPathDTO, | ||||
|     AxiosError, | ||||
|     { hruid: string; language: Language }, | ||||
|     unknown | ||||
| > { | ||||
|     const queryClient = useQueryClient(); | ||||
| 
 | ||||
|     return useMutation({ | ||||
|         mutationFn: async ({ hruid, language }) => learningPathController.deleteLearningPath(hruid, language), | ||||
|         onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function useSearchLearningPathQuery( | ||||
|     query: MaybeRefOrGetter<string | undefined>, | ||||
|     language: MaybeRefOrGetter<string | undefined>, | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue"; | |||
| import SingleTheme from "@/views/SingleTheme.vue"; | ||||
| import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; | ||||
| import authService from "@/services/auth/auth-service"; | ||||
| import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; | ||||
| import { allowRedirect, Redirect } from "@/utils/redirect.ts"; | ||||
| 
 | ||||
| const router = createRouter({ | ||||
|  | @ -106,6 +107,12 @@ const router = createRouter({ | |||
|             component: SingleDiscussion, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/my-content", | ||||
|             name: "OwnLearningContentPage", | ||||
|             component: OwnLearningContentPage, | ||||
|             meta: { requiresAuth: true }, | ||||
|         }, | ||||
|         { | ||||
|             path: "/learningPath", | ||||
|             children: [ | ||||
|  |  | |||
|  | @ -250,7 +250,7 @@ | |||
|                     </template> | ||||
|                 </v-list-itemF> | ||||
|                 <v-divider></v-divider> | ||||
|                 <div v-if="props.learningObjectHruid"> | ||||
|                 <div> | ||||
|                     <using-query-result | ||||
|                         :query-result="learningObjectListQueryResult" | ||||
|                         v-slot="learningObjects: { data: LearningObject[] }" | ||||
|  |  | |||
|  | @ -0,0 +1,81 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useLearningObjectListForAdminQuery } from "@/queries/learning-objects.ts"; | ||||
|     import OwnLearningObjectsView from "@/views/own-learning-content/learning-objects/OwnLearningObjectsView.vue"; | ||||
|     import OwnLearningPathsView from "@/views/own-learning-content/learning-paths/OwnLearningPathsView.vue"; | ||||
|     import authService from "@/services/auth/auth-service.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { LearningObject } from "@/data-objects/learning-objects/learning-object"; | ||||
|     import { ref, type Ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useGetAllLearningPathsByAdminQuery } from "@/queries/learning-paths"; | ||||
|     import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const learningObjectsQuery = useLearningObjectListForAdminQuery( | ||||
|         authService.authState.user?.profile.preferred_username, | ||||
|     ); | ||||
| 
 | ||||
|     const learningPathsQuery = useGetAllLearningPathsByAdminQuery( | ||||
|         authService.authState.user?.profile.preferred_username, | ||||
|     ); | ||||
| 
 | ||||
|     type Tab = "learningObjects" | "learningPaths"; | ||||
|     const tab: Ref<Tab> = ref("learningObjects"); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="tab-pane-container"> | ||||
|         <h1 class="title">{{ t("ownLearningContentTitle") }}</h1> | ||||
| 
 | ||||
|         <v-tabs v-model="tab"> | ||||
|             <v-tab value="learningObjects">{{ t("learningObjects") }}</v-tab> | ||||
|             <v-tab value="learningPaths">{{ t("learningPaths") }}</v-tab> | ||||
|         </v-tabs> | ||||
| 
 | ||||
|         <v-tabs-window | ||||
|             v-model="tab" | ||||
|             class="main-content" | ||||
|         > | ||||
|             <v-tabs-window-item | ||||
|                 value="learningObjects" | ||||
|                 class="main-content" | ||||
|             > | ||||
|                 <using-query-result | ||||
|                     :query-result="learningObjectsQuery" | ||||
|                     v-slot="response: { data: LearningObject[] }" | ||||
|                 > | ||||
|                     <own-learning-objects-view :learningObjects="response.data"></own-learning-objects-view> | ||||
|                 </using-query-result> | ||||
|             </v-tabs-window-item> | ||||
|             <v-tabs-window-item value="learningPaths"> | ||||
|                 <using-query-result | ||||
|                     :query-result="learningPathsQuery" | ||||
|                     v-slot="response: { data: LearningPathDTO[] }" | ||||
|                 > | ||||
|                     <own-learning-paths-view :learningPaths="response.data" /> | ||||
|                 </using-query-result> | ||||
|             </v-tabs-window-item> | ||||
|         </v-tabs-window> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     h1 { | ||||
|         color: #0e6942; | ||||
|         text-transform: uppercase; | ||||
|         font-weight: bolder; | ||||
|         font-size: 50px; | ||||
|     } | ||||
| 
 | ||||
|     .tab-pane-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         height: 100%; | ||||
|         padding: 20px 30px; | ||||
|     } | ||||
|     .main-content { | ||||
|         flex: 1 1; | ||||
|         height: 100%; | ||||
|     } | ||||
| </style> | ||||
|  | @ -0,0 +1,59 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { LearningObject } from "@/data-objects/learning-objects/learning-object"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import LearningObjectContentView from "../../learning-paths/learning-object/content/LearningObjectContentView.vue"; | ||||
|     import ButtonWithConfirmation from "@/components/ButtonWithConfirmation.vue"; | ||||
|     import { useDeleteLearningObjectMutation, useLearningObjectHTMLQuery } from "@/queries/learning-objects"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         selectedLearningObject?: LearningObject; | ||||
|     }>(); | ||||
| 
 | ||||
|     const learningObjectQueryResult = useLearningObjectHTMLQuery( | ||||
|         () => props.selectedLearningObject?.key, | ||||
|         () => props.selectedLearningObject?.language, | ||||
|         () => props.selectedLearningObject?.version, | ||||
|     ); | ||||
| 
 | ||||
|     const { isPending, mutate } = useDeleteLearningObjectMutation(); | ||||
| 
 | ||||
|     function deleteLearningObject(): void { | ||||
|         if (props.selectedLearningObject) { | ||||
|             mutate({ | ||||
|                 hruid: props.selectedLearningObject.key, | ||||
|                 language: props.selectedLearningObject.language, | ||||
|                 version: props.selectedLearningObject.version, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-card | ||||
|         v-if="selectedLearningObject" | ||||
|         :title="t('previewFor') + selectedLearningObject.title" | ||||
|     > | ||||
|         <template v-slot:text> | ||||
|             <using-query-result | ||||
|                 :query-result="learningObjectQueryResult" | ||||
|                 v-slot="response: { data: Document }" | ||||
|             > | ||||
|                 <learning-object-content-view :learning-object-content="response.data"></learning-object-content-view> | ||||
|             </using-query-result> | ||||
|         </template> | ||||
|         <template v-slot:actions> | ||||
|             <button-with-confirmation | ||||
|                 @confirm="deleteLearningObject" | ||||
|                 prepend-icon="mdi mdi-delete" | ||||
|                 color="red" | ||||
|                 :text="t('delete')" | ||||
|                 :confirmQueryText="t('learningObjectDeleteQuery')" | ||||
|             /> | ||||
|         </template> | ||||
|     </v-card> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  | @ -0,0 +1,82 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useUploadLearningObjectMutation } from "@/queries/learning-objects"; | ||||
|     import { ref, watch, type Ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { VFileUpload } from "vuetify/labs/VFileUpload"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const dialogOpen = ref(false); | ||||
| 
 | ||||
|     interface ContainsErrorString { | ||||
|         error: string; | ||||
|     } | ||||
| 
 | ||||
|     const fileToUpload: Ref<File | undefined> = ref(undefined); | ||||
| 
 | ||||
|     const { isPending, error, isError, isSuccess, mutate } = useUploadLearningObjectMutation(); | ||||
| 
 | ||||
|     watch(isSuccess, (newIsSuccess) => { | ||||
|         if (newIsSuccess) { | ||||
|             dialogOpen.value = false; | ||||
|             fileToUpload.value = undefined; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     function uploadFile() { | ||||
|         if (fileToUpload.value) { | ||||
|             mutate({ learningObjectZip: fileToUpload.value }); | ||||
|         } | ||||
|     } | ||||
| </script> | ||||
| <template> | ||||
|     <v-dialog | ||||
|         max-width="500" | ||||
|         v-model="dialogOpen" | ||||
|     > | ||||
|         <template v-slot:activator="{ props: activatorProps }"> | ||||
|             <v-btn | ||||
|                 prepend-icon="mdi mdi-plus" | ||||
|                 :text="t('newLearningObject')" | ||||
|                 v-bind="activatorProps" | ||||
|                 color="rgb(14, 105, 66)" | ||||
|                 size="large" | ||||
|             > | ||||
|             </v-btn> | ||||
|         </template> | ||||
| 
 | ||||
|         <template v-slot:default="{ isActive }"> | ||||
|             <v-card :title="t('learningObjectUploadTitle')"> | ||||
|                 <v-card-text> | ||||
|                     <v-file-upload | ||||
|                         icon="mdi-upload" | ||||
|                         v-model="fileToUpload" | ||||
|                         :disabled="isPending" | ||||
|                     ></v-file-upload> | ||||
|                     <v-alert | ||||
|                         v-if="error" | ||||
|                         icon="mdi mdi-alert-circle" | ||||
|                         type="error" | ||||
|                         :title="t('uploadFailed')" | ||||
|                         :text="t((error.response?.data as ContainsErrorString).error ?? error.message)" | ||||
|                     ></v-alert> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer></v-spacer> | ||||
|                     <v-btn | ||||
|                         :text="t('cancel')" | ||||
|                         @click="isActive.value = false" | ||||
|                     ></v-btn> | ||||
|                     <v-btn | ||||
|                         :text="t('upload')" | ||||
|                         @click="uploadFile()" | ||||
|                         :loading="isPending" | ||||
|                         :disabled="!fileToUpload" | ||||
|                     ></v-btn> | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </template> | ||||
|     </v-dialog> | ||||
| </template> | ||||
| <style scoped></style> | ||||
|  | @ -0,0 +1,79 @@ | |||
| <script setup lang="ts"> | ||||
|     import type { LearningObject } from "@/data-objects/learning-objects/learning-object"; | ||||
|     import LearningObjectUploadButton from "@/views/own-learning-content/learning-objects/LearningObjectUploadButton.vue"; | ||||
|     import LearningObjectPreviewCard from "./LearningObjectPreviewCard.vue"; | ||||
|     import { computed, ref, watch, type Ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const props = defineProps<{ | ||||
|         learningObjects: LearningObject[]; | ||||
|     }>(); | ||||
| 
 | ||||
|     const tableHeaders = [ | ||||
|         { title: t("hruid"), width: "250px", key: "key" }, | ||||
|         { title: t("language"), width: "50px", key: "language" }, | ||||
|         { title: t("version"), width: "50px", key: "version" }, | ||||
|         { title: t("title"), key: "title" }, | ||||
|     ]; | ||||
| 
 | ||||
|     const selectedLearningObjects: Ref<LearningObject[]> = ref([]); | ||||
| 
 | ||||
|     watch( | ||||
|         () => props.learningObjects, | ||||
|         () => (selectedLearningObjects.value = []), | ||||
|     ); | ||||
| 
 | ||||
|     const selectedLearningObject = computed(() => | ||||
|         selectedLearningObjects.value ? selectedLearningObjects.value[0] : undefined, | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="root"> | ||||
|         <div class="table-container"> | ||||
|             <learning-object-upload-button /> | ||||
|             <v-data-table | ||||
|                 class="table" | ||||
|                 v-model="selectedLearningObjects" | ||||
|                 :items="props.learningObjects" | ||||
|                 :headers="tableHeaders" | ||||
|                 select-strategy="single" | ||||
|                 show-select | ||||
|                 return-object | ||||
|             /> | ||||
|         </div> | ||||
|         <div | ||||
|             class="preview-container" | ||||
|             v-if="selectedLearningObject" | ||||
|         > | ||||
|             <learning-object-preview-card | ||||
|                 class="preview" | ||||
|                 :selectedLearningObject="selectedLearningObject" | ||||
|             /> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .root { | ||||
|         display: flex; | ||||
|         gap: 20px; | ||||
|         padding: 20px; | ||||
|         flex-wrap: wrap; | ||||
|     } | ||||
|     .preview-container { | ||||
|         flex: 1; | ||||
|         min-width: 400px; | ||||
|     } | ||||
|     .table-container { | ||||
|         flex: 1; | ||||
|     } | ||||
|     .preview { | ||||
|         width: 100%; | ||||
|     } | ||||
|     .table { | ||||
|         width: 100%; | ||||
|         margin-top: 20px; | ||||
|     } | ||||
| </style> | ||||
|  | @ -0,0 +1,147 @@ | |||
| <script setup lang="ts"> | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { computed, ref, watch, type Ref } from "vue"; | ||||
|     import JsonEditorVue from "json-editor-vue"; | ||||
|     import ButtonWithConfirmation from "@/components/ButtonWithConfirmation.vue"; | ||||
|     import { | ||||
|         useDeleteLearningPathMutation, | ||||
|         usePostLearningPathMutation, | ||||
|         usePutLearningPathMutation, | ||||
|     } from "@/queries/learning-paths"; | ||||
|     import { Language } from "@/data-objects/language"; | ||||
|     import type { LearningPath } from "@dwengo-1/common/interfaces/learning-content"; | ||||
|     import type { AxiosError } from "axios"; | ||||
|     import { parse } from "uuid"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         selectedLearningPath?: LearningPath; | ||||
|     }>(); | ||||
| 
 | ||||
|     const { isPending, mutate, error: deleteError, isSuccess: deleteSuccess } = useDeleteLearningPathMutation(); | ||||
| 
 | ||||
|     const DEFAULT_LEARNING_PATH: Partial<LearningPath> = { | ||||
|         language: "en", | ||||
|         hruid: "...", | ||||
|         title: "...", | ||||
|         description: "...", | ||||
|         nodes: [ | ||||
|             { | ||||
|                 learningobject_hruid: "...", | ||||
|                 language: Language.English, | ||||
|                 version: 1, | ||||
|                 start_node: true, | ||||
|                 transitions: [ | ||||
|                     { | ||||
|                         default: true, | ||||
|                         condition: t("hintRemoveIfUnconditionalTransition"), | ||||
|                         next: { | ||||
|                             hruid: "...", | ||||
|                             version: 1, | ||||
|                             language: "...", | ||||
|                         }, | ||||
|                     }, | ||||
|                 ], | ||||
|             }, | ||||
|         ], | ||||
|     }; | ||||
| 
 | ||||
|     const { isPending: isPostPending, error: postError, mutate: doPost } = usePostLearningPathMutation(); | ||||
|     const { isPending: isPutPending, error: putError, mutate: doPut } = usePutLearningPathMutation(); | ||||
| 
 | ||||
|     const learningPath: Ref<Partial<LearningPath> | string> = ref(DEFAULT_LEARNING_PATH); | ||||
| 
 | ||||
|     const parsedLearningPath = computed(() => | ||||
|         typeof learningPath.value === "string" ? (JSON.parse(learningPath.value) as LearningPath) : learningPath.value, | ||||
|     ); | ||||
| 
 | ||||
|     watch( | ||||
|         () => props.selectedLearningPath, | ||||
|         () => (learningPath.value = props.selectedLearningPath ?? DEFAULT_LEARNING_PATH), | ||||
|     ); | ||||
| 
 | ||||
|     function uploadLearningPath(): void { | ||||
|         if (props.selectedLearningPath) { | ||||
|             doPut({ learningPath: parsedLearningPath.value }); | ||||
|         } else { | ||||
|             doPost({ learningPath: parsedLearningPath.value }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function deleteLearningPath(): void { | ||||
|         if (props.selectedLearningPath) { | ||||
|             mutate({ | ||||
|                 hruid: props.selectedLearningPath.hruid, | ||||
|                 language: props.selectedLearningPath.language as Language, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function extractErrorMessage(error: AxiosError): string { | ||||
|         return (error.response?.data as { error: string }).error ?? error.message; | ||||
|     } | ||||
| 
 | ||||
|     const isIdModified = computed( | ||||
|         () => | ||||
|             props.selectedLearningPath !== undefined && | ||||
|             (props.selectedLearningPath.hruid !== parsedLearningPath.value.hruid || | ||||
|                 props.selectedLearningPath.language !== parsedLearningPath.value.language), | ||||
|     ); | ||||
| 
 | ||||
|     function getErrorMessage(): string | null { | ||||
|         if (postError.value) { | ||||
|             return t(extractErrorMessage(postError.value)); | ||||
|         } else if (putError.value) { | ||||
|             return t(extractErrorMessage(putError.value)); | ||||
|         } else if (deleteError.value) { | ||||
|             return t(extractErrorMessage(deleteError.value)); | ||||
|         } else if (isIdModified.value) { | ||||
|             return t("learningPathCantModifyId"); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-card :title="props.selectedLearningPath ? t('editLearningPath') : t('newLearningPath')"> | ||||
|         <template v-slot:text> | ||||
|             <json-editor-vue v-model="learningPath"></json-editor-vue> | ||||
|             <v-alert | ||||
|                 v-if="postError || putError || deleteError || isIdModified" | ||||
|                 icon="mdi mdi-alert-circle" | ||||
|                 type="error" | ||||
|                 :title="t('error')" | ||||
|                 :text="getErrorMessage()!" | ||||
|             ></v-alert> | ||||
|         </template> | ||||
|         <template v-slot:actions> | ||||
|             <v-btn | ||||
|                 @click="uploadLearningPath" | ||||
|                 prependIcon="mdi mdi-check" | ||||
|                 :loading="isPostPending || isPutPending" | ||||
|                 :disabled="parsedLearningPath.hruid === DEFAULT_LEARNING_PATH.hruid || isIdModified" | ||||
|             > | ||||
|                 {{ props.selectedLearningPath ? t("saveChanges") : t("create") }} | ||||
|             </v-btn> | ||||
|             <button-with-confirmation | ||||
|                 @confirm="deleteLearningPath" | ||||
|                 :disabled="!props.selectedLearningPath" | ||||
|                 :text="t('delete')" | ||||
|                 color="red" | ||||
|                 prependIcon="mdi mdi-delete" | ||||
|                 :confirmQueryText="t('learningPathDeleteQuery')" | ||||
|             /> | ||||
|             <v-btn | ||||
|                 :href="`/learningPath/${props.selectedLearningPath?.hruid}/${props.selectedLearningPath?.language}/start`" | ||||
|                 target="_blank" | ||||
|                 prepend-icon="mdi mdi-open-in-new" | ||||
|                 :disabled="!props.selectedLearningPath" | ||||
|             > | ||||
|                 {{ t("open") }} | ||||
|             </v-btn> | ||||
|         </template> | ||||
|     </v-card> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped></style> | ||||
|  | @ -0,0 +1,77 @@ | |||
| <script setup lang="ts"> | ||||
|     import LearningPathPreviewCard from "./LearningPathPreviewCard.vue"; | ||||
|     import { computed, ref, watch, type Ref } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; | ||||
| 
 | ||||
|     const { t } = useI18n(); | ||||
|     const props = defineProps<{ | ||||
|         learningPaths: LearningPathDTO[]; | ||||
|     }>(); | ||||
| 
 | ||||
|     const tableHeaders = [ | ||||
|         { title: t("hruid"), width: "250px", key: "hruid" }, | ||||
|         { title: t("language"), width: "50px", key: "language" }, | ||||
|         { title: t("title"), key: "title" }, | ||||
|     ]; | ||||
| 
 | ||||
|     const selectedLearningPaths: Ref<LearningPathDTO[]> = ref([]); | ||||
| 
 | ||||
|     const selectedLearningPath = computed(() => | ||||
|         selectedLearningPaths.value ? selectedLearningPaths.value[0] : undefined, | ||||
|     ); | ||||
| 
 | ||||
|     watch( | ||||
|         () => props.learningPaths, | ||||
|         () => (selectedLearningPaths.value = []), | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="root"> | ||||
|         <div class="table-container"> | ||||
|             <v-data-table | ||||
|                 class="table" | ||||
|                 v-model="selectedLearningPaths" | ||||
|                 :items="props.learningPaths" | ||||
|                 :headers="tableHeaders" | ||||
|                 select-strategy="single" | ||||
|                 show-select | ||||
|                 return-object | ||||
|             /> | ||||
|         </div> | ||||
|         <div class="preview-container"> | ||||
|             <learning-path-preview-card | ||||
|                 class="preview" | ||||
|                 :selectedLearningPath="selectedLearningPath" | ||||
|             /> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
|     .fab { | ||||
|         position: absolute; | ||||
|         right: 20px; | ||||
|         bottom: 20px; | ||||
|     } | ||||
|     .root { | ||||
|         display: flex; | ||||
|         gap: 20px; | ||||
|         padding: 20px; | ||||
|         flex-wrap: wrap; | ||||
|     } | ||||
|     .preview-container { | ||||
|         flex: 1; | ||||
|         min-width: 400px; | ||||
|     } | ||||
|     .table-container { | ||||
|         flex: 1; | ||||
|     } | ||||
|     .preview { | ||||
|         width: 100%; | ||||
|     } | ||||
|     .table { | ||||
|         width: 100%; | ||||
|     } | ||||
| </style> | ||||
|  | @ -0,0 +1,28 @@ | |||
| import { LearningObjectController } from "../../src/controllers/learning-objects"; | ||||
| import { Language } from "@dwengo-1/common/util/language"; | ||||
| import { beforeEach, describe, expect, it } from "vitest"; | ||||
| 
 | ||||
| describe("Test controller learning object", () => { | ||||
|     let controller: LearningObjectController; | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|         controller = new LearningObjectController(); | ||||
|     }); | ||||
| 
 | ||||
|     it("can get the metadata of a learning object", async () => { | ||||
|         const result = await controller.getMetadata("u_id01", Language.English, 1); | ||||
|         expect(result).not.toBeNull(); | ||||
|         for (const property of ["key", "version", "language", "title"]) { | ||||
|             expect(result).toHaveProperty(property); | ||||
|         } | ||||
|         expect(result.key).toEqual("u_id01"); | ||||
|         expect(result.version).toEqual(1); | ||||
|         expect(result.language).toEqual(Language.English); | ||||
|     }); | ||||
| 
 | ||||
|     it("can get the HTML of a learning object", async () => { | ||||
|         const result = await controller.getHTML("u_id01", Language.English, 1); | ||||
|         expect(result).toHaveProperty("body"); | ||||
|         expect(result.body).toHaveProperty("innerHTML"); | ||||
|     }); | ||||
| }); | ||||
|  | @ -18,4 +18,9 @@ describe("Test controller learning paths", () => { | |||
|         const data = await controller.getAllByThemeAndLanguage("kiks", Language.Dutch); | ||||
|         expect(data).to.have.length.greaterThan(0); | ||||
|     }); | ||||
| 
 | ||||
|     it("Can get all learning paths administrated by a certain user.", async () => { | ||||
|         const data = await controller.getAllByAdminRaw("user"); | ||||
|         expect(data.length).toBe(0); // This user does not administrate any learning paths in the test data.
 | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ export default defineConfig({ | |||
|     resolve: { | ||||
|         alias: { | ||||
|             "@": fileURLToPath(new URL("./src", import.meta.url)), | ||||
|             "@dwengo-1/common": fileURLToPath(new URL("../common/src", import.meta.url)), | ||||
|         }, | ||||
|     }, | ||||
|     build: { | ||||
|  |  | |||
							
								
								
									
										2709
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										2709
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger