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",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
|
"express-fileupload": "^1.5.1",
|
||||||
"express-jwt": "^8.5.1",
|
"express-jwt": "^8.5.1",
|
||||||
"gift-pegjs": "^1.0.2",
|
"gift-pegjs": "^1.0.2",
|
||||||
"isomorphic-dompurify": "^2.22.0",
|
"isomorphic-dompurify": "^2.22.0",
|
||||||
|
@ -37,9 +38,11 @@
|
||||||
"jwks-rsa": "^3.1.0",
|
"jwks-rsa": "^3.1.0",
|
||||||
"loki-logger-ts": "^1.0.2",
|
"loki-logger-ts": "^1.0.2",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"response-time": "^2.3.3",
|
"response-time": "^2.3.3",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"winston-loki": "^6.1.3"
|
"winston-loki": "^6.1.3"
|
||||||
|
@ -48,10 +51,13 @@
|
||||||
"@mikro-orm/cli": "6.4.12",
|
"@mikro-orm/cli": "6.4.12",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/express-fileupload": "^1.5.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/response-time": "^2.3.8",
|
"@types/response-time": "^2.3.8",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/unzipper": "^0.10.11",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
|
|
|
@ -7,6 +7,9 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js';
|
||||||
import { NotFoundException } from '../exceptions/not-found-exception.js';
|
import { NotFoundException } from '../exceptions/not-found-exception.js';
|
||||||
import { envVars, getEnvVar } from '../util/envVars.js';
|
import { envVars, getEnvVar } from '../util/envVars.js';
|
||||||
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||||
|
import { UploadedFile } from 'express-fileupload';
|
||||||
|
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request';
|
||||||
|
import { requireFields } from './error-helper.js';
|
||||||
|
|
||||||
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO {
|
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO {
|
||||||
if (!req.params.hruid) {
|
if (!req.params.hruid) {
|
||||||
|
@ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier {
|
function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier {
|
||||||
if (!req.query.hruid) {
|
const { hruid, language } = req.params;
|
||||||
throw new BadRequestException('HRUID is required.');
|
requireFields({ hruid });
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
hruid: req.params.hruid,
|
hruid,
|
||||||
language: (req.query.language as Language) || FALLBACK_LANG,
|
language: (language as Language) || FALLBACK_LANG,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
|
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
|
||||||
const learningPathId = getLearningPathIdentifierFromRequest(req);
|
if (req.query.admin) {
|
||||||
const full = req.query.full;
|
// 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[];
|
res.json(learningObjects);
|
||||||
if (full) {
|
|
||||||
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
|
|
||||||
} else {
|
} 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> {
|
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);
|
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 { NotFoundException } from '../exceptions/not-found-exception.js';
|
||||||
import { Group } from '../entities/assignments/group.entity.js';
|
import { Group } from '../entities/assignments/group.entity.js';
|
||||||
import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js';
|
import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js';
|
||||||
|
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js';
|
||||||
|
import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||||
|
import { getTeacher } from '../services/teachers.js';
|
||||||
|
import { requireFields } from './error-helper.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch learning paths based on query parameters.
|
* Fetch learning paths based on query parameters.
|
||||||
*/
|
*/
|
||||||
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
|
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
|
||||||
const hruids = req.query.hruid;
|
const admin = req.query.admin;
|
||||||
const themeKey = req.query.theme as string;
|
if (admin) {
|
||||||
const searchQuery = req.query.search as string;
|
const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string);
|
||||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
res.json(paths);
|
||||||
|
|
||||||
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 {
|
} 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);
|
const forGroupNo = req.query.forGroup as string;
|
||||||
res.json(learningPaths.data);
|
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,
|
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 { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||||
import { RequiredEntityData } from '@mikro-orm/core';
|
import { RequiredEntityData } from '@mikro-orm/core';
|
||||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||||
import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js';
|
|
||||||
|
|
||||||
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
||||||
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
||||||
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] });
|
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,7 +23,21 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
|
||||||
language: language,
|
language: language,
|
||||||
$or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }],
|
$or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }],
|
||||||
},
|
},
|
||||||
populate: ['nodes', 'nodes.transitions'],
|
populate: ['nodes', 'nodes.transitions', 'admins'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all learning paths which have the user with the given username as an administrator.
|
||||||
|
*/
|
||||||
|
public async findAllByAdminUsername(adminUsername: string): Promise<LearningPath[]> {
|
||||||
|
return this.findAll({
|
||||||
|
where: {
|
||||||
|
admins: {
|
||||||
|
username: adminUsername,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
populate: ['nodes', 'nodes.transitions', 'admins'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,18 +49,15 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
|
||||||
return this.em.create(LearningPathTransition, transitionData);
|
return this.em.create(LearningPathTransition, transitionData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveLearningPathNodesAndTransitions(
|
/**
|
||||||
path: LearningPath,
|
* Deletes the learning path with the given hruid and language.
|
||||||
nodes: LearningPathNode[],
|
* @returns the deleted learning path or null if it was not found.
|
||||||
transitions: LearningPathTransition[],
|
*/
|
||||||
options?: { preventOverwrite?: boolean }
|
public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
||||||
): Promise<void> {
|
const path = await this.findByHruidAndLanguage(hruid, language);
|
||||||
if (options?.preventOverwrite && (await this.findOne(path))) {
|
if (path) {
|
||||||
throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.');
|
await this.em.removeAndFlush(path);
|
||||||
}
|
}
|
||||||
const em = this.getEntityManager();
|
return path;
|
||||||
await em.persistAndFlush(path);
|
|
||||||
await Promise.all(nodes.map(async (it) => em.persistAndFlush(it)));
|
|
||||||
await Promise.all(transitions.map(async (it) => em.persistAndFlush(it)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ export class Attachment {
|
||||||
@ManyToOne({
|
@ManyToOne({
|
||||||
entity: () => LearningObject,
|
entity: () => LearningObject,
|
||||||
primary: true,
|
primary: true,
|
||||||
|
deleteRule: 'cascade',
|
||||||
})
|
})
|
||||||
learningObject!: LearningObject;
|
learningObject!: LearningObject;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
import { ArrayType, Collection, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||||
import { Attachment } from './attachment.entity.js';
|
import { Attachment } from './attachment.entity.js';
|
||||||
import { Teacher } from '../users/teacher.entity.js';
|
import { Teacher } from '../users/teacher.entity.js';
|
||||||
import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js';
|
import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js';
|
||||||
|
@ -28,7 +28,7 @@ export class LearningObject {
|
||||||
@ManyToMany({
|
@ManyToMany({
|
||||||
entity: () => Teacher,
|
entity: () => Teacher,
|
||||||
})
|
})
|
||||||
admins!: Teacher[];
|
admins: Collection<Teacher> = new Collection<Teacher>(this);
|
||||||
|
|
||||||
@Property({ type: 'string' })
|
@Property({ type: 'string' })
|
||||||
title!: string;
|
title!: string;
|
||||||
|
@ -84,7 +84,7 @@ export class LearningObject {
|
||||||
entity: () => Attachment,
|
entity: () => Attachment,
|
||||||
mappedBy: 'learningObject',
|
mappedBy: 'learningObject',
|
||||||
})
|
})
|
||||||
attachments: Attachment[] = [];
|
attachments: Collection<Attachment> = new Collection<Attachment>(this);
|
||||||
|
|
||||||
@Property({ type: 'blob' })
|
@Property({ type: 'blob' })
|
||||||
content!: Buffer;
|
content!: Buffer;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
|
import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
|
||||||
import { LearningPath } from './learning-path.entity.js';
|
import { LearningPath } from './learning-path.entity.js';
|
||||||
import { LearningPathTransition } from './learning-path-transition.entity.js';
|
import { LearningPathTransition } from './learning-path-transition.entity.js';
|
||||||
import { Language } from '@dwengo-1/common/util/language';
|
import { Language } from '@dwengo-1/common/util/language';
|
||||||
|
@ -26,7 +26,7 @@ export class LearningPathNode {
|
||||||
@Property({ type: 'bool' })
|
@Property({ type: 'bool' })
|
||||||
startNode!: boolean;
|
startNode!: boolean;
|
||||||
|
|
||||||
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' })
|
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] })
|
||||||
transitions!: Collection<LearningPathTransition>;
|
transitions!: Collection<LearningPathTransition>;
|
||||||
|
|
||||||
@Property({ length: 3 })
|
@Property({ length: 3 })
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
import { Cascade, Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||||
import { Teacher } from '../users/teacher.entity.js';
|
import { Teacher } from '../users/teacher.entity.js';
|
||||||
import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
|
import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
|
||||||
import { LearningPathNode } from './learning-path-node.entity.js';
|
import { LearningPathNode } from './learning-path-node.entity.js';
|
||||||
|
@ -24,6 +24,6 @@ export class LearningPath {
|
||||||
@Property({ type: 'blob', nullable: true })
|
@Property({ type: 'blob', nullable: true })
|
||||||
image: Buffer | null = null;
|
image: Buffer | null = null;
|
||||||
|
|
||||||
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
|
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] })
|
||||||
nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this);
|
nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 express from 'express';
|
||||||
import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js';
|
import {
|
||||||
|
getAllLearningObjects,
|
||||||
|
getAttachment,
|
||||||
|
getLearningObject,
|
||||||
|
getLearningObjectHTML,
|
||||||
|
handleDeleteLearningObject,
|
||||||
|
handlePostLearningObject,
|
||||||
|
} from '../controllers/learning-objects.js';
|
||||||
import submissionRoutes from './submissions.js';
|
import submissionRoutes from './submissions.js';
|
||||||
import questionRoutes from './questions.js';
|
import questionRoutes from './questions.js';
|
||||||
import { 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();
|
const router = express.Router();
|
||||||
|
|
||||||
|
@ -18,12 +27,20 @@ const router = express.Router();
|
||||||
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
|
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
|
||||||
router.get('/', authenticatedOnly, getAllLearningObjects);
|
router.get('/', authenticatedOnly, getAllLearningObjects);
|
||||||
|
|
||||||
|
router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject);
|
||||||
|
|
||||||
// Parameter: hruid of learning object
|
// Parameter: hruid of learning object
|
||||||
// Query: language
|
// Query: language
|
||||||
// Route to fetch data of one learning object based on its hruid
|
// Route to fetch data of one learning object based on its hruid
|
||||||
// Example: http://localhost:3000/learningObject/un_ai7
|
// Example: http://localhost:3000/learningObject/un_ai7
|
||||||
router.get('/:hruid', authenticatedOnly, getLearningObject);
|
router.get('/:hruid', authenticatedOnly, getLearningObject);
|
||||||
|
|
||||||
|
// Parameter: hruid of learning object
|
||||||
|
// Query: language
|
||||||
|
// Route to delete a learning object based on its hruid.
|
||||||
|
// Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1
|
||||||
|
router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject);
|
||||||
|
|
||||||
router.use('/:hruid/submissions', submissionRoutes);
|
router.use('/:hruid/submissions', submissionRoutes);
|
||||||
|
|
||||||
router.use('/:hruid/:version/questions', questionRoutes);
|
router.use('/:hruid/:version/questions', questionRoutes);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { getLearningPaths } from '../controllers/learning-paths.js';
|
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
|
||||||
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
|
import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js';
|
||||||
|
import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
@ -24,5 +25,9 @@ const router = express.Router();
|
||||||
// Example: http://localhost:3000/learningPath?theme=kiks
|
// Example: http://localhost:3000/learningPath?theme=kiks
|
||||||
|
|
||||||
router.get('/', authenticatedOnly, getLearningPaths);
|
router.get('/', authenticatedOnly, getLearningPaths);
|
||||||
|
router.post('/', teachersOnly, postLearningPath);
|
||||||
|
|
||||||
|
router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath);
|
||||||
|
router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
|
||||||
);
|
);
|
||||||
return learningObjects.filter((it) => it !== null);
|
return learningObjects.filter((it) => it !== null);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all learning objects containing the given username as an admin.
|
||||||
|
*/
|
||||||
|
async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> {
|
||||||
|
const learningObjectRepo = getLearningObjectRepository();
|
||||||
|
const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername);
|
||||||
|
return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default databaseLearningObjectProvider;
|
export default databaseLearningObjectProvider;
|
||||||
|
|
|
@ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain all learning objects who have the user with the given username as an admin.
|
||||||
|
*/
|
||||||
|
async getLearningObjectsAdministratedBy(_adminUsername: string): Promise<FilteredLearningObject[]> {
|
||||||
|
return []; // The dwengo database does not contain any learning objects administrated by users.
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default dwengoApiLearningObjectProvider;
|
export default dwengoApiLearningObjectProvider;
|
||||||
|
|
|
@ -20,4 +20,9 @@ export interface LearningObjectProvider {
|
||||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||||
*/
|
*/
|
||||||
getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>;
|
getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain all learning object who have the user with the given username as an admin.
|
||||||
|
*/
|
||||||
|
getLearningObjectsAdministratedBy(username: string): Promise<FilteredLearningObject[]>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js';
|
||||||
import { envVars, getEnvVar } from '../../util/envVars.js';
|
import { envVars, getEnvVar } from '../../util/envVars.js';
|
||||||
import databaseLearningObjectProvider from './database-learning-object-provider.js';
|
import databaseLearningObjectProvider from './database-learning-object-provider.js';
|
||||||
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||||
|
import { getLearningObjectRepository, getTeacherRepository } from '../../data/repositories.js';
|
||||||
|
import { processLearningObjectZip } from './learning-object-zip-processing-service.js';
|
||||||
|
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||||
|
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
|
||||||
|
import { NotFoundException } from '../../exceptions/not-found-exception.js';
|
||||||
|
|
||||||
function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider {
|
function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider {
|
||||||
if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
|
if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
|
||||||
|
@ -42,6 +47,66 @@ const learningObjectService = {
|
||||||
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
|
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
|
||||||
return getProvider(id).getLearningObjectHTML(id);
|
return getProvider(id).getLearningObjectHTML(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain all learning objects administrated by the user with the given username.
|
||||||
|
*/
|
||||||
|
async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> {
|
||||||
|
return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the learning object in the given zip file in the database.
|
||||||
|
* @param learningObjectPath The path where the uploaded learning object resides.
|
||||||
|
* @param admins The usernames of the users which should be administrators of the learning object.
|
||||||
|
*/
|
||||||
|
async storeLearningObject(learningObjectPath: string, admins: string[]): Promise<LearningObject> {
|
||||||
|
const learningObjectRepository = getLearningObjectRepository();
|
||||||
|
const learningObject = await processLearningObjectZip(learningObjectPath);
|
||||||
|
|
||||||
|
if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
|
||||||
|
learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup the admin teachers based on their usernames and add them to the admins of the learning object.
|
||||||
|
const teacherRepo = getTeacherRepository();
|
||||||
|
const adminTeachers = await Promise.all(admins.map(async (it) => teacherRepo.findByUsername(it)));
|
||||||
|
adminTeachers.forEach((it) => {
|
||||||
|
if (it !== null) {
|
||||||
|
learningObject.admins.add(it);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await learningObjectRepository.save(learningObject, { preventOverwrite: true });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
learningObjectRepository.getEntityManager().clear();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return learningObject;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the learning object with the given identifier.
|
||||||
|
*/
|
||||||
|
async deleteLearningObject(id: LearningObjectIdentifier): Promise<LearningObject | null> {
|
||||||
|
const learningObjectRepository = getLearningObjectRepository();
|
||||||
|
return await learningObjectRepository.removeByIdentifier(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of the usernames of the administrators of the learning object with the given identifier.
|
||||||
|
* @throws NotFoundException if the specified learning object was not found in the database.
|
||||||
|
*/
|
||||||
|
async getAdmins(id: LearningObjectIdentifier): Promise<string[]> {
|
||||||
|
const learningObjectRepo = getLearningObjectRepository();
|
||||||
|
const learningObject = await learningObjectRepo.findByIdentifier(id);
|
||||||
|
if (!learningObject) {
|
||||||
|
throw new NotFoundException('learningObjectNotFound');
|
||||||
|
}
|
||||||
|
return learningObject.admins.map((admin) => admin.username);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default learningObjectService;
|
export default learningObjectService;
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
import unzipper from 'unzipper';
|
||||||
|
import mime from 'mime-types';
|
||||||
|
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||||
|
import { getAttachmentRepository, getLearningObjectRepository } from '../../data/repositories.js';
|
||||||
|
import { BadRequestException } from '../../exceptions/bad-request-exception.js';
|
||||||
|
import { LearningObjectMetadata } from '@dwengo-1/common/interfaces/learning-content';
|
||||||
|
import { DwengoContentType } from './processing/content-type.js';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/;
|
||||||
|
const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an uploaded zip file and construct a LearningObject from its contents.
|
||||||
|
* @param filePath Path of the zip file to process.
|
||||||
|
*/
|
||||||
|
export async function processLearningObjectZip(filePath: string): Promise<LearningObject> {
|
||||||
|
let zip: unzipper.CentralDirectory;
|
||||||
|
try {
|
||||||
|
zip = await unzipper.Open.file(filePath);
|
||||||
|
} catch (_: unknown) {
|
||||||
|
throw new BadRequestException('invalidZip');
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata: LearningObjectMetadata | undefined = undefined;
|
||||||
|
const attachments: { name: string; content: Buffer }[] = [];
|
||||||
|
let content: Buffer | undefined = undefined;
|
||||||
|
|
||||||
|
if (zip.files.length === 0) {
|
||||||
|
throw new BadRequestException('emptyZip');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
zip.files.map(async (file) => {
|
||||||
|
if (file.type !== 'Directory') {
|
||||||
|
if (METADATA_PATH_REGEX.test(file.path)) {
|
||||||
|
metadata = await processMetadataJson(file);
|
||||||
|
} else if (CONTENT_PATH_REGEX.test(file.path)) {
|
||||||
|
content = await processFile(file);
|
||||||
|
} else {
|
||||||
|
attachments.push({
|
||||||
|
name: file.path,
|
||||||
|
content: await processFile(file),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
throw new BadRequestException('missingMetadata');
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
throw new BadRequestException('missingIndex');
|
||||||
|
}
|
||||||
|
|
||||||
|
const learningObject = createLearningObject(metadata, content, attachments);
|
||||||
|
|
||||||
|
return learningObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLearningObject(metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer }[]): LearningObject {
|
||||||
|
const learningObjectRepo = getLearningObjectRepository();
|
||||||
|
const attachmentRepo = getAttachmentRepository();
|
||||||
|
|
||||||
|
const returnValue = {
|
||||||
|
callbackUrl: metadata.return_value?.callback_url ?? '',
|
||||||
|
callbackSchema: metadata.return_value?.callback_schema ? JSON.stringify(metadata.return_value.callback_schema) : '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!metadata.target_ages || metadata.target_ages.length === 0) {
|
||||||
|
throw new BadRequestException('targetAgesMandatory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const learningObject = learningObjectRepo.create({
|
||||||
|
admins: [],
|
||||||
|
available: metadata.available ?? true,
|
||||||
|
content: content,
|
||||||
|
contentType: metadata.content_type as DwengoContentType,
|
||||||
|
copyright: metadata.copyright ?? '',
|
||||||
|
description: metadata.description ?? '',
|
||||||
|
educationalGoals: metadata.educational_goals ?? [],
|
||||||
|
hruid: metadata.hruid,
|
||||||
|
keywords: metadata.keywords,
|
||||||
|
language: metadata.language,
|
||||||
|
license: metadata.license ?? '',
|
||||||
|
returnValue,
|
||||||
|
skosConcepts: metadata.skos_concepts ?? [],
|
||||||
|
teacherExclusive: metadata.teacher_exclusive,
|
||||||
|
title: metadata.title,
|
||||||
|
version: metadata.version,
|
||||||
|
estimatedTime: metadata.estimated_time ?? 1,
|
||||||
|
targetAges: metadata.target_ages ?? [],
|
||||||
|
difficulty: metadata.difficulty ?? 1,
|
||||||
|
uuid: v4(),
|
||||||
|
});
|
||||||
|
const attachmentEntities = attachments.map((it) =>
|
||||||
|
attachmentRepo.create({
|
||||||
|
name: it.name,
|
||||||
|
content: it.content,
|
||||||
|
mimeType: mime.lookup(it.name) || 'text/plain',
|
||||||
|
learningObject,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
attachmentEntities.forEach((it) => {
|
||||||
|
learningObject.attachments.add(it);
|
||||||
|
});
|
||||||
|
return learningObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processMetadataJson(file: unzipper.File): Promise<LearningObjectMetadata> {
|
||||||
|
const buf = await file.buffer();
|
||||||
|
const content = buf.toString();
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFile(file: unzipper.File): Promise<Buffer> {
|
||||||
|
return await file.buffer();
|
||||||
|
}
|
|
@ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language';
|
||||||
import { Group } from '../../entities/assignments/group.entity';
|
import { Group } from '../../entities/assignments/group.entity';
|
||||||
import { Collection } from '@mikro-orm/core';
|
import { Collection } from '@mikro-orm/core';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
import { getLogger } from '../../logging/initalize.js';
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
|
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
|
||||||
|
@ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) {
|
|
||||||
throw new Error('At least one of the learning objects on this path could not be found.');
|
// Ignore all learning objects that cannot be found such that the rest of the learning path keeps working.
|
||||||
|
for (const [key, value] of nullableNodesToLearningObjects) {
|
||||||
|
if (value === null) {
|
||||||
|
logger.warn(`Learning object ${key.learningObjectHruid}/${key.language}/${key.version} not found!`);
|
||||||
|
nullableNodesToLearningObjects.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
|
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
|
||||||
}
|
}
|
||||||
|
@ -102,7 +110,15 @@ async function convertNode(
|
||||||
!personalizedFor || // If we do not want a personalized learning path, keep all transitions
|
!personalizedFor || // If we do not want a personalized learning path, keep all transitions
|
||||||
isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
|
isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
|
||||||
)
|
)
|
||||||
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects));
|
.map((trans, i) => {
|
||||||
|
try {
|
||||||
|
return convertTransition(trans, i, nodesToLearningObjects);
|
||||||
|
} catch (_: unknown) {
|
||||||
|
logger.error(`Transition could not be resolved: ${JSON.stringify(trans)}`);
|
||||||
|
return undefined; // Do not crash on invalid transitions, just ignore them so the rest of the learning path keeps working.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((it) => it !== undefined);
|
||||||
return {
|
return {
|
||||||
_id: learningObject.uuid,
|
_id: learningObject.uuid,
|
||||||
language: learningObject.language,
|
language: learningObject.language,
|
||||||
|
@ -164,6 +180,7 @@ function convertTransition(
|
||||||
return {
|
return {
|
||||||
_id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
|
_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.
|
default: false, // We don't work with default transitions but retain this for backwards compatibility.
|
||||||
|
condition: transition.condition,
|
||||||
next: {
|
next: {
|
||||||
_id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
|
_id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
|
||||||
hruid: transition.next.learningObjectHruid,
|
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.
|
* Search learning paths in the database using the given search string.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -74,6 +74,10 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
|
||||||
|
|
||||||
return searchResults ?? [];
|
return searchResults ?? [];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getLearningPathsAdministratedBy(_adminUsername: string) {
|
||||||
|
return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default dwengoApiLearningPathProvider;
|
export default dwengoApiLearningPathProvider;
|
||||||
|
|
|
@ -15,4 +15,9 @@ export interface LearningPathProvider {
|
||||||
* Search learning paths in the data source using the given search string.
|
* Search learning paths in the data source using the given search string.
|
||||||
*/
|
*/
|
||||||
searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>;
|
searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all learning paths which have the teacher with the given user as an administrator.
|
||||||
|
*/
|
||||||
|
getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
|
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
|
||||||
import databaseLearningPathProvider from './database-learning-path-provider.js';
|
import databaseLearningPathProvider from './database-learning-path-provider.js';
|
||||||
import { envVars, getEnvVar } from '../../util/envVars.js';
|
import { envVars, getEnvVar } from '../../util/envVars.js';
|
||||||
import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
|
import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
|
||||||
import { Language } from '@dwengo-1/common/util/language';
|
import { Language } from '@dwengo-1/common/util/language';
|
||||||
import { Group } from '../../entities/assignments/group.entity.js';
|
import { Group } from '../../entities/assignments/group.entity.js';
|
||||||
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
|
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
|
||||||
|
@ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js';
|
||||||
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
|
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
|
||||||
import { mapToTeacher } from '../../interfaces/teacher.js';
|
import { mapToTeacher } from '../../interfaces/teacher.js';
|
||||||
import { Collection } from '@mikro-orm/core';
|
import { Collection } from '@mikro-orm/core';
|
||||||
|
import { NotFoundException } from '../../exceptions/not-found-exception.js';
|
||||||
|
import { BadRequestException } from '../../exceptions/bad-request-exception.js';
|
||||||
|
import learningObjectService from '../learning-objects/learning-object-service.js';
|
||||||
|
|
||||||
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
|
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
|
||||||
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
|
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
|
||||||
|
@ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L
|
||||||
const fromNode = nodes.find(
|
const fromNode = nodes.find(
|
||||||
(it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version
|
(it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version
|
||||||
)!;
|
)!;
|
||||||
const transitions = nodeDto.transitions
|
const transitions = nodeDto.transitions.map((transDto, i) => {
|
||||||
.map((transDto, i) => {
|
const toNode = nodes.find(
|
||||||
const toNode = nodes.find(
|
(it) =>
|
||||||
(it) =>
|
it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version
|
||||||
it.learningObjectHruid === transDto.next.hruid &&
|
);
|
||||||
it.language === transDto.next.language &&
|
|
||||||
it.version === transDto.next.version
|
|
||||||
);
|
|
||||||
|
|
||||||
if (toNode) {
|
if (toNode) {
|
||||||
return repo.createTransition({
|
return repo.createTransition({
|
||||||
transitionNumber: i,
|
transitionNumber: i,
|
||||||
node: fromNode,
|
node: fromNode,
|
||||||
next: toNode,
|
next: toNode,
|
||||||
condition: transDto.condition ?? 'true',
|
condition: transDto.condition ?? 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return undefined;
|
throw new BadRequestException(
|
||||||
})
|
`Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.`
|
||||||
.filter((it) => it)
|
);
|
||||||
.map((it) => it!);
|
});
|
||||||
|
|
||||||
fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions);
|
fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions);
|
||||||
});
|
});
|
||||||
|
@ -105,6 +105,14 @@ const learningPathService = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the learning paths administrated by the teacher with the given username.
|
||||||
|
*/
|
||||||
|
async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> {
|
||||||
|
const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername)));
|
||||||
|
return providerResponses.flat();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search learning paths in the data source using the given search string.
|
* Search learning paths in the data source using the given search string.
|
||||||
*/
|
*/
|
||||||
|
@ -119,11 +127,67 @@ const learningPathService = {
|
||||||
* Add a new learning path to the database.
|
* Add a new learning path to the database.
|
||||||
* @param dto Learning path DTO from which the learning path will be created.
|
* @param dto Learning path DTO from which the learning path will be created.
|
||||||
* @param admins Teachers who should become an admin of the learning path.
|
* @param admins Teachers who should become an admin of the learning path.
|
||||||
|
* @returns the created learning path.
|
||||||
*/
|
*/
|
||||||
async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> {
|
async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<LearningPathEntity> {
|
||||||
const repo = getLearningPathRepository();
|
const repo = getLearningPathRepository();
|
||||||
|
|
||||||
|
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
|
||||||
|
if (!dto.hruid.startsWith(userContentPrefix)) {
|
||||||
|
dto.hruid = userContentPrefix + dto.hruid;
|
||||||
|
}
|
||||||
|
|
||||||
const path = mapToLearningPath(dto, admins);
|
const path = mapToLearningPath(dto, admins);
|
||||||
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;
|
language: Language;
|
||||||
start_node?: boolean;
|
start_node?: boolean;
|
||||||
transitions: Transition[];
|
transitions: Transition[];
|
||||||
created_at: string;
|
created_at?: string;
|
||||||
updatedAt: string;
|
updatedAt?: string;
|
||||||
done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
|
done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,6 +79,8 @@ export interface LearningObjectMetadata {
|
||||||
target_ages: number[];
|
target_ages: number[];
|
||||||
content_type: string; // Markdown, image, etc.
|
content_type: string; // Markdown, image, etc.
|
||||||
content_location?: string;
|
content_location?: string;
|
||||||
|
copyright?: string;
|
||||||
|
license?: string;
|
||||||
skos_concepts?: string[];
|
skos_concepts?: string[];
|
||||||
return_value?: ReturnValue;
|
return_value?: ReturnValue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,22 +3,28 @@ FROM node:22 AS build-stage
|
||||||
# install simple http server for serving static content
|
# install simple http server for serving static content
|
||||||
RUN npm install -g http-server
|
RUN npm install -g http-server
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app/dwengo
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY ./frontend/package.json ./frontend/
|
COPY ./frontend/package.json ./frontend/
|
||||||
|
# Frontend depends on common
|
||||||
|
COPY common/package.json ./common/
|
||||||
|
|
||||||
RUN npm install --silent
|
RUN npm install --silent
|
||||||
|
|
||||||
# Build the frontend
|
# Build the frontend
|
||||||
|
|
||||||
# Root tsconfig.json
|
# Root tsconfig.json
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json tsconfig.build.json ./
|
||||||
COPY assets ./assets/
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
COPY assets ./assets
|
||||||
|
COPY common ./common
|
||||||
|
|
||||||
|
RUN npm run build --workspace=common
|
||||||
|
|
||||||
|
WORKDIR /app/dwengo/frontend
|
||||||
|
|
||||||
COPY frontend ./
|
COPY frontend ./
|
||||||
|
|
||||||
|
@ -28,8 +34,8 @@ FROM nginx:stable AS production-stage
|
||||||
|
|
||||||
COPY config/nginx/nginx.conf /etc/nginx/nginx.conf
|
COPY config/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
COPY --from=build-stage /app/assets /usr/share/nginx/html/assets
|
COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets
|
||||||
COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html
|
COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"@tanstack/vue-query": "^5.69.0",
|
"@tanstack/vue-query": "^5.69.0",
|
||||||
"@vueuse/core": "^13.1.0",
|
"@vueuse/core": "^13.1.0",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
|
"json-editor-vue": "^0.18.1",
|
||||||
"oidc-client-ts": "^3.1.0",
|
"oidc-client-ts": "^3.1.0",
|
||||||
"rollup": "^4.40.0",
|
"rollup": "^4.40.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts";
|
import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts";
|
||||||
import { useThemeQuery } from "@/queries/themes.ts";
|
import { useThemeQuery } from "@/queries/themes.ts";
|
||||||
import type { Theme } from "@/data-objects/theme.ts";
|
import type { Theme } from "@/data-objects/theme.ts";
|
||||||
|
import authService from "@/services/auth/auth-service";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedTheme: { type: String, required: true },
|
selectedTheme: { type: String, required: true },
|
||||||
|
@ -33,6 +34,8 @@
|
||||||
cards.value = themes;
|
cards.value = themes;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isTeacher = computed(() => authService.authState.activeRole === "teacher");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -73,6 +76,23 @@
|
||||||
class="fill-height grey-bg-card"
|
class="fill-height grey-bg-card"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
v-if="isTeacher"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="4"
|
||||||
|
lg="4"
|
||||||
|
class="d-flex"
|
||||||
|
>
|
||||||
|
<ThemeCard
|
||||||
|
path="/my-content"
|
||||||
|
:is-absolute-path="true"
|
||||||
|
:title="t('ownLearningContentTitle')"
|
||||||
|
:description="t('ownLearningContentDescription')"
|
||||||
|
icon="mdi-pencil"
|
||||||
|
class="fill-height grey-bg-card"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
v-for="card in cards"
|
v-for="card in cards"
|
||||||
:key="card.key"
|
:key="card.key"
|
||||||
|
|
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 assets
|
||||||
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
|
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
|
||||||
|
import { useLocale } from "vuetify";
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const { current: vuetifyLocale } = useLocale();
|
||||||
|
|
||||||
const role = auth.authState.activeRole;
|
const role = auth.authState.activeRole;
|
||||||
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
|
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
|
||||||
|
@ -32,6 +34,7 @@
|
||||||
// Logic to change the language of the website to the selected language
|
// Logic to change the language of the website to the selected language
|
||||||
function changeLanguage(langCode: string): void {
|
function changeLanguage(langCode: string): void {
|
||||||
locale.value = langCode;
|
locale.value = langCode;
|
||||||
|
vuetifyLocale.value = langCode;
|
||||||
localStorage.setItem("user-lang", langCode);
|
localStorage.setItem("user-lang", langCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
<div v-if="isError">
|
<div v-if="isError">
|
||||||
<v-empty-state
|
<v-empty-state
|
||||||
icon="mdi-alert-circle-outline"
|
icon="mdi-alert-circle-outline"
|
||||||
:text="errorMessage"
|
:text="t(errorMessage)"
|
||||||
:title="t('error_title')"
|
:title="t('error_title')"
|
||||||
></v-empty-state>
|
></v-empty-state>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -37,6 +37,33 @@ export abstract class BaseController {
|
||||||
return response.data;
|
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> {
|
protected async delete<T>(path: string, queryParams?: QueryParams): Promise<T> {
|
||||||
const response = await apiClient.delete<T>(this.absolutePathFor(path), { params: queryParams });
|
const response = await apiClient.delete<T>(this.absolutePathFor(path), { params: queryParams });
|
||||||
BaseController.assertSuccessResponse(response);
|
BaseController.assertSuccessResponse(response);
|
||||||
|
|
|
@ -14,4 +14,16 @@ export class LearningObjectController extends BaseController {
|
||||||
async getHTML(hruid: string, language: Language, version: number): Promise<Document> {
|
async getHTML(hruid: string, language: Language, version: number): Promise<Document> {
|
||||||
return this.get<Document>(`/${hruid}/html`, { language, version }, "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 { 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 type { Language } from "@/data-objects/language.ts";
|
||||||
import { single } from "@/utils/response-assertions.ts";
|
import { LearningPath } from "@/data-objects/learning-paths/learning-path";
|
||||||
import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts";
|
import { NotFoundException } from "@/exception/not-found-exception";
|
||||||
|
import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content";
|
||||||
|
|
||||||
export class LearningPathController extends BaseController {
|
export class LearningPathController extends BaseController {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -24,7 +24,10 @@ export class LearningPathController extends BaseController {
|
||||||
assignmentNo: forGroup?.assignmentNo,
|
assignmentNo: forGroup?.assignmentNo,
|
||||||
classId: forGroup?.classId,
|
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[]> {
|
async getAllByThemeAndLanguage(theme: string, language: Language): Promise<LearningPath[]> {
|
||||||
const dtos = await this.get<LearningPathDTO[]>("/", { theme, language });
|
const dtos = await this.get<LearningPathDTO[]>("/", { theme, language });
|
||||||
|
@ -36,4 +39,20 @@ export class LearningPathController extends BaseController {
|
||||||
const dtos = await this.get<LearningPathDTO[]>("/", query);
|
const dtos = await this.get<LearningPathDTO[]>("/", query);
|
||||||
return dtos.map((dto) => LearningPath.fromDTO(dto));
|
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 { 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 {
|
export class LearningPathNode {
|
||||||
public readonly learningobjectHruid: string;
|
public readonly learningobjectHruid: string;
|
||||||
|
@ -14,7 +14,7 @@ export class LearningPathNode {
|
||||||
learningobjectHruid: string;
|
learningobjectHruid: string;
|
||||||
version: number;
|
version: number;
|
||||||
language: Language;
|
language: Language;
|
||||||
transitions: { next: LearningPathNode; default: boolean }[];
|
transitions: { next: LearningPathNode; default?: boolean }[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
|
@ -22,7 +22,7 @@ export class LearningPathNode {
|
||||||
this.learningobjectHruid = options.learningobjectHruid;
|
this.learningobjectHruid = options.learningobjectHruid;
|
||||||
this.version = options.version;
|
this.version = options.version;
|
||||||
this.language = options.language;
|
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.createdAt = options.createdAt;
|
||||||
this.updatedAt = options.updatedAt;
|
this.updatedAt = options.updatedAt;
|
||||||
this.done = options.done || false;
|
this.done = options.done || false;
|
||||||
|
@ -50,8 +50,8 @@ export class LearningPathNode {
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((it) => it !== undefined),
|
.filter((it) => it !== undefined),
|
||||||
createdAt: new Date(dto.created_at),
|
createdAt: dto.created_at ? new Date(dto.created_at) : new Date(),
|
||||||
updatedAt: new Date(dto.updatedAt),
|
updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : new Date(),
|
||||||
done: dto.done,
|
done: dto.done,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Language } from "@/data-objects/language.ts";
|
import type { Language } from "@/data-objects/language.ts";
|
||||||
import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.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 {
|
export interface LearningPathNodeDTO {
|
||||||
_id: string;
|
_id: string;
|
||||||
|
@ -77,20 +77,26 @@ export class LearningPath {
|
||||||
hruid: dto.hruid,
|
hruid: dto.hruid,
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
amountOfNodes: dto.num_nodes,
|
amountOfNodes: dto.num_nodes ?? dto.nodes.length,
|
||||||
amountOfNodesLeft: dto.num_nodes_left,
|
amountOfNodesLeft: dto.num_nodes_left ?? dto.nodes.length,
|
||||||
keywords: dto.keywords.split(" "),
|
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),
|
startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes),
|
||||||
image: dto.image,
|
image: dto.image,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO {
|
static getStartNode(dto: LearningPathDTO): LearningObjectNode {
|
||||||
const startNodeDtos = dto.nodes.filter((it) => it.start_node === true);
|
const startNodeDtos = dto.nodes.filter((it) => it.start_node === true);
|
||||||
if (startNodeDtos.length < 1) {
|
if (startNodeDtos.length < 1) {
|
||||||
// The learning path has no starting node -> use the first node.
|
// 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.
|
} // The learning path has 1 or more starting nodes -> use the first start node.
|
||||||
return startNodeDtos[0];
|
return startNodeDtos[0];
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,5 +135,36 @@
|
||||||
"valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein",
|
"valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein",
|
||||||
"creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut",
|
"creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||||
"no-assignments": "Derzeit gibt es keine Zuweisungen.",
|
"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",
|
"valid-username": "please enter a valid username",
|
||||||
"creationFailed": "creation failed, please try again",
|
"creationFailed": "creation failed, please try again",
|
||||||
"no-assignments": "There are currently no assignments.",
|
"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",
|
"valid-username": "veuillez entrer un nom d'utilisateur valide",
|
||||||
"creationFailed": "échec de la création, veuillez réessayer",
|
"creationFailed": "échec de la création, veuillez réessayer",
|
||||||
"no-assignments": "Il n'y a actuellement aucun travail.",
|
"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",
|
"valid-username": "voer een geldige gebruikersnaam in",
|
||||||
"creationFailed": "aanmaak mislukt, probeer het opnieuw",
|
"creationFailed": "aanmaak mislukt, probeer het opnieuw",
|
||||||
"no-assignments": "Er zijn momenteel geen opdrachten.",
|
"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 * as directives from "vuetify/directives";
|
||||||
import i18n from "./i18n/i18n.ts";
|
import i18n from "./i18n/i18n.ts";
|
||||||
|
|
||||||
|
// JSON-editor
|
||||||
|
import JsonEditorVue from "json-editor-vue";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
import { aliases, mdi } from "vuetify/iconsets/mdi";
|
import { aliases, mdi } from "vuetify/iconsets/mdi";
|
||||||
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";
|
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";
|
||||||
|
import { de, en, fr, nl } from "vuetify/locale";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
app.use(JsonEditorVue, {});
|
||||||
|
|
||||||
const link = document.createElement("link");
|
const link = document.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
|
@ -32,6 +37,11 @@ const vuetify = createVuetify({
|
||||||
mdi,
|
mdi,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
locale: {
|
||||||
|
locale: i18n.global.locale,
|
||||||
|
fallback: "en",
|
||||||
|
messages: { nl, en, de, fr },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
import { type MaybeRefOrGetter, toValue } from "vue";
|
import { type MaybeRefOrGetter, toValue } from "vue";
|
||||||
import type { Language } from "@/data-objects/language.ts";
|
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 { getLearningObjectController } from "@/controllers/controllers.ts";
|
||||||
import type { LearningObject } from "@/data-objects/learning-objects/learning-object.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 { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
|
||||||
export const LEARNING_OBJECT_KEY = "learningObject";
|
export const LEARNING_OBJECT_KEY = "learningObject";
|
||||||
const learningObjectController = getLearningObjectController();
|
const learningObjectController = getLearningObjectController();
|
||||||
|
@ -24,15 +31,15 @@ export function useLearningObjectMetadataQuery(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLearningObjectHTMLQuery(
|
export function useLearningObjectHTMLQuery(
|
||||||
hruid: MaybeRefOrGetter<string>,
|
hruid: MaybeRefOrGetter<string | undefined>,
|
||||||
language: MaybeRefOrGetter<Language>,
|
language: MaybeRefOrGetter<Language | undefined>,
|
||||||
version: MaybeRefOrGetter<number>,
|
version: MaybeRefOrGetter<number | undefined>,
|
||||||
): UseQueryReturnType<Document, Error> {
|
): UseQueryReturnType<Document, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version],
|
queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)];
|
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)),
|
enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)),
|
||||||
});
|
});
|
||||||
|
@ -55,3 +62,49 @@ export function useLearningObjectListForPathQuery(
|
||||||
enabled: () => Boolean(toValue(learningPath)),
|
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 MaybeRefOrGetter, toValue } from "vue";
|
||||||
import type { Language } from "@/data-objects/language.ts";
|
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 { 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";
|
export const LEARNING_PATH_KEY = "learningPath";
|
||||||
const learningPathController = getLearningPathController();
|
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(
|
export function useSearchLearningPathQuery(
|
||||||
query: MaybeRefOrGetter<string | undefined>,
|
query: MaybeRefOrGetter<string | undefined>,
|
||||||
language: 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 SingleTheme from "@/views/SingleTheme.vue";
|
||||||
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
|
||||||
import authService from "@/services/auth/auth-service";
|
import authService from "@/services/auth/auth-service";
|
||||||
|
import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue";
|
||||||
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
|
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
@ -106,6 +107,12 @@ const router = createRouter({
|
||||||
component: SingleDiscussion,
|
component: SingleDiscussion,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/my-content",
|
||||||
|
name: "OwnLearningContentPage",
|
||||||
|
component: OwnLearningContentPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/learningPath",
|
path: "/learningPath",
|
||||||
children: [
|
children: [
|
||||||
|
|
|
@ -250,7 +250,7 @@
|
||||||
</template>
|
</template>
|
||||||
</v-list-itemF>
|
</v-list-itemF>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<div v-if="props.learningObjectHruid">
|
<div>
|
||||||
<using-query-result
|
<using-query-result
|
||||||
:query-result="learningObjectListQueryResult"
|
:query-result="learningObjectListQueryResult"
|
||||||
v-slot="learningObjects: { data: LearningObject[] }"
|
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);
|
const data = await controller.getAllByThemeAndLanguage("kiks", Language.Dutch);
|
||||||
expect(data).to.have.length.greaterThan(0);
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
"@dwengo-1/common": fileURLToPath(new URL("../common/src", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|
2709
package-lock.json
generated
2709
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue