From 86ba4ea11e5184b57b3da84324de32b18f2a9356 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 5 May 2025 23:15:22 +0200 Subject: [PATCH 01/47] feat(backend): Verwerking van leerobjecten in ZIP-formaat. --- backend/package.json | 4 + .../learning-object-service.ts | 23 +++- .../learning-object-zip-processing-service.ts | 63 +++++++++++ package-lock.json | 103 ++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 backend/src/services/learning-objects/learning-object-zip-processing-service.ts diff --git a/backend/package.json b/backend/package.json index 7943d61d..7b2ab878 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,8 @@ "@mikro-orm/postgresql": "6.4.12", "@mikro-orm/reflection": "6.4.12", "@mikro-orm/sqlite": "6.4.12", + "@types/mime-types": "^2.1.4", + "@types/unzipper": "^0.10.11", "axios": "^1.8.2", "cors": "^2.8.5", "cross": "^1.0.0", @@ -37,8 +39,10 @@ "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", + "mime-types": "^3.0.1", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", + "unzipper": "^0.12.3", "uuid": "^11.1.0", "winston": "^3.17.0", "winston-loki": "^6.1.3" diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 7b4f47fc..4f5409fd 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -2,7 +2,13 @@ import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provid import { LearningObjectProvider } from './learning-object-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; import databaseLearningObjectProvider from './database-learning-object-provider.js'; -import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { + FilteredLearningObject, + LearningObjectIdentifierDTO, + LearningPathIdentifier +} from '@dwengo-1/common/interfaces/learning-content'; +import {getLearningObjectRepository} from "../../data/repositories"; +import {processLearningObjectZip} from "./learning-object-zip-processing-service"; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -42,6 +48,21 @@ const learningObjectService = { async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { return getProvider(id).getLearningObjectHTML(id); }, + + + /** + * Store the learning object in the given zip file in the database. + */ + async storeLearningObject(learningObjectPath: string): Promise { + const learningObjectRepository = getLearningObjectRepository(); + const learningObject = await processLearningObjectZip(learningObjectPath); + + if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { + throw Error("Learning object name must start with the user content prefix!"); + } + + await learningObjectRepository.save(learningObject, {preventOverwrite: true}); + } }; export default learningObjectService; diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts new file mode 100644 index 00000000..3dbd5915 --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -0,0 +1,63 @@ +import unzipper from 'unzipper'; +import mime from 'mime-types'; +import {LearningObjectMetadata} from "@dwengo-1/common/dist/interfaces/learning-content"; +import {LearningObject} from "../../entities/content/learning-object.entity"; +import {getAttachmentRepository, getLearningObjectRepository} from "../../data/repositories"; + +/** + * 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 { + const learningObjectRepo = getLearningObjectRepository(); + const attachmentRepo = getAttachmentRepository(); + + const zip = await unzipper.Open.file(filePath); + + let metadata: LearningObjectMetadata | null = null; + const attachments: {name: string, content: Buffer}[] = []; + let content: Buffer | null = null; + + for (const file of zip.files) { + if (file.type === "Directory") { + throw Error("The learning object zip file should not contain directories."); + } else if (file.path === "metadata.json") { + metadata = await processMetadataJson(file); + } else if (file.path.startsWith("index.")) { + content = await processFile(file); + } else { + attachments.push({ + name: file.path, + content: await processFile(file) + }); + } + } + + if (!metadata) { + throw Error("Missing metadata.json file"); + } + if (!content) { + throw Error("Missing index file"); + } + + const learningObject = learningObjectRepo.create(metadata); + const attachmentEntities = attachments.map(it => attachmentRepo.create({ + name: it.name, + content: it.content, + mimeType: mime.lookup(it.name) || "text/plain", + learningObject + })) + learningObject.attachments.push(...attachmentEntities); + + return learningObject; +} + +async function processMetadataJson(file: unzipper.File): LearningObjectMetadata { + const buf = await file.buffer(); + const content = buf.toString(); + return JSON.parse(content); +} + +async function processFile(file: unzipper.File): Promise { + return await file.buffer(); +} diff --git a/package-lock.json b/package-lock.json index d1a3b3a7..221d9fb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "@mikro-orm/postgresql": "6.4.12", "@mikro-orm/reflection": "6.4.12", "@mikro-orm/sqlite": "6.4.12", + "@types/mime-types": "^2.1.4", + "@types/unzipper": "^0.10.11", "axios": "^1.8.2", "cors": "^2.8.5", "cross": "^1.0.0", @@ -50,8 +52,10 @@ "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", + "mime-types": "^3.0.1", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", + "unzipper": "^0.12.3", "uuid": "^11.1.0", "winston": "^3.17.0", "winston-loki": "^6.1.3" @@ -1716,6 +1720,12 @@ "version": "1.3.5", "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "license": "MIT" @@ -1784,6 +1794,15 @@ "license": "MIT", "optional": true }, + "node_modules/@types/unzipper": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz", + "integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "license": "MIT" @@ -2711,6 +2730,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.0", "license": "MIT", @@ -3259,6 +3284,12 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "license": "MIT", @@ -3515,6 +3546,45 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/dwengo-1-docs": { "resolved": "docs", "link": true @@ -5053,6 +5123,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "license": "ISC" @@ -5820,6 +5896,8 @@ }, "node_modules/mime-types": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -6165,6 +6243,12 @@ "node": ">=6" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.19", "dev": true, @@ -6843,6 +6927,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "license": "ISC", @@ -8411,6 +8501,19 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "dev": true, From 78353d6b656dc83da2e519ca846835cd87afb601 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 5 May 2025 23:38:18 +0200 Subject: [PATCH 02/47] feat(backend): Controller en route voor het aanmaken van leerobjecten aangemaakt. --- backend/package.json | 2 + backend/src/controllers/learning-objects.ts | 8 +++ backend/src/routes/learning-objects.ts | 11 +++- .../learning-object-service.ts | 3 +- .../learning-object-zip-processing-service.ts | 9 ++-- package-lock.json | 54 +++++++++++++++++++ 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/backend/package.json b/backend/package.json index 7b2ab878..a95cd334 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "cross-env": "^7.0.3", "dotenv": "^16.4.7", "express": "^5.0.1", + "express-fileupload": "^1.5.1", "express-jwt": "^8.5.1", "gift-pegjs": "^1.0.2", "isomorphic-dompurify": "^2.22.0", @@ -51,6 +52,7 @@ "@mikro-orm/cli": "6.4.12", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/express-fileupload": "^1.5.1", "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 83aa33f9..3622912c 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -7,6 +7,7 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { envVars, getEnvVar } from '../util/envVars.js'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import {UploadedFile} from "express-fileupload"; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { if (!req.params.hruid) { @@ -72,3 +73,10 @@ export async function getAttachment(req: Request, res: Response): Promise } res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } + +export async function handlePostLearningObject(req: Request, res: Response): Promise { + if (!req.files || !req.files[0]) { + throw new BadRequestException('No file uploaded'); + } + await learningObjectService.storeLearningObject((req.files[0] as UploadedFile).tempFilePath); +} diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index 7532765b..254d7ebc 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,8 +1,15 @@ import express from 'express'; -import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; +import { + getAllLearningObjects, + getAttachment, + getLearningObject, + getLearningObjectHTML, + handlePostLearningObject +} from '../controllers/learning-objects.js'; import submissionRoutes from './submissions.js'; import questionRoutes from './questions.js'; +import fileUpload from "express-fileupload"; const router = express.Router(); @@ -18,6 +25,8 @@ const router = express.Router(); // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie router.get('/', getAllLearningObjects); +router.post('/', fileUpload({useTempFiles: true}), handlePostLearningObject) + // Parameter: hruid of learning object // Query: language // Route to fetch data of one learning object based on its hruid diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 4f5409fd..9a0912ae 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -9,6 +9,7 @@ import { } from '@dwengo-1/common/interfaces/learning-content'; import {getLearningObjectRepository} from "../../data/repositories"; import {processLearningObjectZip} from "./learning-object-zip-processing-service"; +import {BadRequestException} from "../../exceptions/bad-request-exception"; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -58,7 +59,7 @@ const learningObjectService = { const learningObject = await processLearningObjectZip(learningObjectPath); if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { - throw Error("Learning object name must start with the user content prefix!"); + throw new BadRequestException("Learning object name must start with the user content prefix!"); } await learningObjectRepository.save(learningObject, {preventOverwrite: true}); diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts index 3dbd5915..fcf80e2b 100644 --- a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -1,8 +1,9 @@ import unzipper from 'unzipper'; import mime from 'mime-types'; -import {LearningObjectMetadata} from "@dwengo-1/common/dist/interfaces/learning-content"; import {LearningObject} from "../../entities/content/learning-object.entity"; import {getAttachmentRepository, getLearningObjectRepository} from "../../data/repositories"; +import {BadRequestException} from "../../exceptions/bad-request-exception"; +import {LearningObjectMetadata} from "@dwengo-1/common/dist/interfaces/learning-content"; /** * Process an uploaded zip file and construct a LearningObject from its contents. @@ -20,7 +21,7 @@ export async function processLearningObjectZip(filePath: string): Promise=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -4235,6 +4269,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-fileupload": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.5.1.tgz", + "integrity": "sha512-LsYG1ALXEB7vlmjuSw8ABeOctMp8a31aUC5ZF55zuz7O2jLFnmJYrCv10py357ky48aEoBQ/9bVXgFynjvaPmA==", + "license": "MIT", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express-jwt": { "version": "8.5.1", "license": "MIT", @@ -7798,6 +7844,14 @@ "dev": true, "license": "MIT" }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "license": "MIT", From 6600441b08a62f69230f18c9c77087fbe664b7b2 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Sun, 11 May 2025 15:46:53 +0200 Subject: [PATCH 03/47] feat(backend): opvragen van leerobjecten van een leerkracht --- backend/src/controllers/learning-objects.ts | 36 +++++++---- .../content/learning-object-repository.ts | 4 +- .../content/learning-object.entity.ts | 16 ++++- .../database-learning-object-provider.ts | 11 ++++ .../dwengo-api-learning-object-provider.ts | 7 +++ .../learning-object-provider.ts | 5 ++ .../learning-object-service.ts | 28 ++++++++- .../learning-object-zip-processing-service.ts | 59 +++++++++++++------ backend/src/services/teachers.ts | 2 +- .../OwnLearningContentPage.vue | 11 ++++ .../OwnLearningObjectsView.vue | 11 ++++ 11 files changed, 152 insertions(+), 38 deletions(-) create mode 100644 frontend/src/views/own-learning-content/OwnLearningContentPage.vue create mode 100644 frontend/src/views/own-learning-content/OwnLearningObjectsView.vue diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 3622912c..ba8fdef3 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -8,6 +8,7 @@ import { NotFoundException } from '../exceptions/not-found-exception.js'; import { envVars, getEnvVar } from '../util/envVars.js'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; import {UploadedFile} from "express-fileupload"; +import {AuthenticatedRequest} from "../middleware/auth/authenticated-request"; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { if (!req.params.hruid) { @@ -31,17 +32,24 @@ function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentif } export async function getAllLearningObjects(req: Request, res: Response): Promise { - const learningPathId = getLearningPathIdentifierFromRequest(req); - const full = req.query.full; + if (req.query.admin) { // If the admin query parameter is present, the user wants to have all learning objects with this admin. + const learningObjects = + await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string); - let learningObjects: FilteredLearningObject[] | string[]; - if (full) { - learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); - } else { - learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); + res.json(learningObjects); + } else { // Else he/she wants all learning objects on the path specified by the request parameters. + const learningPathId = getLearningPathIdentifierFromRequest(req); + const full = req.query.full; + + let learningObjects: FilteredLearningObject[] | string[]; + if (full) { + learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); + } else { + learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); + } + + res.json({ learningObjects: learningObjects }); } - - res.json({ learningObjects: learningObjects }); } export async function getLearningObject(req: Request, res: Response): Promise { @@ -74,9 +82,13 @@ export async function getAttachment(req: Request, res: Response): Promise res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } -export async function handlePostLearningObject(req: Request, res: Response): Promise { - if (!req.files || !req.files[0]) { +export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise { + if (!req.files || !req.files.learningObject) { throw new BadRequestException('No file uploaded'); } - await learningObjectService.storeLearningObject((req.files[0] as UploadedFile).tempFilePath); + const learningObject = await learningObjectService.storeLearningObject( + (req.files.learningObject as UploadedFile).tempFilePath, + [req.auth!.username] + ); + res.json(learningObject); } diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index 889a1594..d11833dc 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -33,9 +33,9 @@ export class LearningObjectRepository extends DwengoEntityRepository { + public async findAllByAdmin(adminUsername: string): Promise { return this.find( - { admins: teacher }, + { admins: { $contains: adminUsername } }, { populate: ['admins'] } // Make sure to load admin relations ); } diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index e0ae09d6..825bf744 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -1,4 +1,14 @@ -import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { + ArrayType, + Collection, + Embedded, + Entity, + Enum, + ManyToMany, + OneToMany, + PrimaryKey, + Property +} from '@mikro-orm/core'; import { Attachment } from './attachment.entity.js'; import { Teacher } from '../users/teacher.entity.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; @@ -28,7 +38,7 @@ export class LearningObject { @ManyToMany({ entity: () => Teacher, }) - admins!: Teacher[]; + admins: Collection = new Collection(this); @Property({ type: 'string' }) title!: string; @@ -84,7 +94,7 @@ export class LearningObject { entity: () => Attachment, mappedBy: 'learningObject', }) - attachments: Attachment[] = []; + attachments: Collection = new Collection(this); @Property({ type: 'blob' }) content!: Buffer; diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 0b805a56..9d16d820 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -109,6 +109,17 @@ const databaseLearningObjectProvider: LearningObjectProvider = { ); return learningObjects.filter((it) => it !== null); }, + + /** + * Returns all learning objects containing the given username as an admin. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + const learningObjectRepo = getLearningObjectRepository(); + const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername); + return learningObjects + .map(it => convertLearningObject(it)) + .filter(it => it != null); + } }; export default databaseLearningObjectProvider; diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index 4a4bdc54..804d2d20 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { return html; }, + + /** + * Obtain all learning objects who have the user with the given username as an admin. + */ + async getLearningObjectsAdministratedBy(_adminUsername: string): Promise { + return []; // The dwengo database does not contain any learning objects administrated by users. + } }; export default dwengoApiLearningObjectProvider; diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts index 14848bc0..69ad268d 100644 --- a/backend/src/services/learning-objects/learning-object-provider.ts +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -20,4 +20,9 @@ export interface LearningObjectProvider { * Obtain a HTML-rendering of the learning object with the given identifier (as a string). */ getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise; + + /** + * Obtain all learning object who have the user with the given username as an admin. + */ + getLearningObjectsAdministratedBy(username: string): Promise; } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 9a0912ae..9d6c6673 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -7,9 +7,10 @@ import { LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; -import {getLearningObjectRepository} from "../../data/repositories"; +import {getLearningObjectRepository, getTeacherRepository} from "../../data/repositories"; import {processLearningObjectZip} from "./learning-object-zip-processing-service"; import {BadRequestException} from "../../exceptions/bad-request-exception"; +import {LearningObject} from "../../entities/content/learning-object.entity"; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -50,19 +51,40 @@ const learningObjectService = { return getProvider(id).getLearningObjectHTML(id); }, + /** + * Obtain all learning objects administrated by the user with the given username. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + 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): Promise { + async storeLearningObject(learningObjectPath: string, admins: string[]): Promise { const learningObjectRepository = getLearningObjectRepository(); const learningObject = await processLearningObjectZip(learningObjectPath); + console.log(learningObject); if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { - throw new BadRequestException("Learning object name must start with the user content prefix!"); + 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(it => teacherRepo.findByUsername(it)) + ); + adminTeachers.forEach(it => { + if (it != null) { + learningObject.admins.add(it); + } + }); + await learningObjectRepository.save(learningObject, {preventOverwrite: true}); + return learningObject; } }; diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts index fcf80e2b..53c4ca9c 100644 --- a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -5,6 +5,9 @@ import {getAttachmentRepository, getLearningObjectRepository} from "../../data/r import {BadRequestException} from "../../exceptions/bad-request-exception"; import {LearningObjectMetadata} from "@dwengo-1/common/dist/interfaces/learning-content"; +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. @@ -15,40 +18,62 @@ export async function processLearningObjectZip(filePath: string): Promise attachmentRepo.create({ name: it.name, content: it.content, mimeType: mime.lookup(it.name) || "text/plain", learningObject })) - learningObject.attachments.push(...attachmentEntities); + attachmentEntities.forEach(it => learningObject.attachments.add(it)); return learningObject; } diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index 4fdb15be..8f41df8b 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -124,7 +124,7 @@ export async function getTeacherQuestions(username: string, full: boolean): Prom // Find all learning objects that this teacher manages const learningObjectRepository: LearningObjectRepository = getLearningObjectRepository(); - const learningObjects: LearningObject[] = await learningObjectRepository.findAllByTeacher(teacher); + const learningObjects: LearningObject[] = await learningObjectRepository.findAllByAdmin(teacher); if (!learningObjects || learningObjects.length === 0) { return []; diff --git a/frontend/src/views/own-learning-content/OwnLearningContentPage.vue b/frontend/src/views/own-learning-content/OwnLearningContentPage.vue new file mode 100644 index 00000000..85af7153 --- /dev/null +++ b/frontend/src/views/own-learning-content/OwnLearningContentPage.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue b/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue new file mode 100644 index 00000000..85af7153 --- /dev/null +++ b/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue @@ -0,0 +1,11 @@ + + + + + From be1091544c415283dd7e620d82d3abf4a84cd939 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 12 May 2025 00:47:37 +0200 Subject: [PATCH 04/47] feat(frontend): basisimplementatie leerobject upload-UI --- .../content/learning-object-repository.ts | 7 +- .../learning-object-service.ts | 6 +- .../learning-object-zip-processing-service.ts | 67 +++++++++------- frontend/src/controllers/base-controller.ts | 22 ++++++ frontend/src/controllers/learning-objects.ts | 8 ++ frontend/src/queries/learning-objects.ts | 33 ++++++-- frontend/src/router/index.ts | 7 ++ .../LearningObjectUploadButton.vue | 76 +++++++++++++++++++ .../OwnLearningContentPage.vue | 43 ++++++++++- .../OwnLearningObjectsView.vue | 73 +++++++++++++++++- .../OwnLearningPathsView.vue | 9 +++ 11 files changed, 311 insertions(+), 40 deletions(-) create mode 100644 frontend/src/views/own-learning-content/LearningObjectUploadButton.vue create mode 100644 frontend/src/views/own-learning-content/OwnLearningPathsView.vue diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index d11833dc..1dd7c9e0 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Language } from '@dwengo-1/common/util/language'; -import { Teacher } from '../../entities/users/teacher.entity.js'; export class LearningObjectRepository extends DwengoEntityRepository { public async findByIdentifier(identifier: LearningObjectIdentifier): Promise { @@ -35,7 +34,11 @@ export class LearningObjectRepository extends DwengoEntityRepository { return this.find( - { admins: { $contains: adminUsername } }, + { + admins: { + username: adminUsername + } + }, { populate: ['admins'] } // Make sure to load admin relations ); } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 9d6c6673..0a229fde 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -9,7 +9,6 @@ import { } from '@dwengo-1/common/interfaces/learning-content'; import {getLearningObjectRepository, getTeacherRepository} from "../../data/repositories"; import {processLearningObjectZip} from "./learning-object-zip-processing-service"; -import {BadRequestException} from "../../exceptions/bad-request-exception"; import {LearningObject} from "../../entities/content/learning-object.entity"; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { @@ -67,7 +66,6 @@ const learningObjectService = { const learningObjectRepository = getLearningObjectRepository(); const learningObject = await processLearningObjectZip(learningObjectPath); - console.log(learningObject); if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid; } @@ -75,10 +73,10 @@ const learningObjectService = { // 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(it => teacherRepo.findByUsername(it)) + admins.map(async it => teacherRepo.findByUsername(it)) ); adminTeachers.forEach(it => { - if (it != null) { + if (it !== null) { learningObject.admins.add(it); } }); diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts index 53c4ca9c..213c3f17 100644 --- a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -4,6 +4,7 @@ import {LearningObject} from "../../entities/content/learning-object.entity"; import {getAttachmentRepository, getLearningObjectRepository} from "../../data/repositories"; import {BadRequestException} from "../../exceptions/bad-request-exception"; import {LearningObjectMetadata} from "@dwengo-1/common/dist/interfaces/learning-content"; +import { ReturnValue } from '../../entities/content/return-value.entity'; const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/; const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/; @@ -13,33 +14,38 @@ const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/; * @param filePath Path of the zip file to process. */ export async function processLearningObjectZip(filePath: string): Promise { - const learningObjectRepo = getLearningObjectRepository(); - const attachmentRepo = getAttachmentRepository(); + let zip: unzipper.CentralDirectory; + try { + zip = await unzipper.Open.file(filePath); + } catch(_: unknown) { + throw new BadRequestException("invalid_zip"); + } - const zip = await unzipper.Open.file(filePath); let metadata: LearningObjectMetadata | undefined = undefined; const attachments: {name: string, content: Buffer}[] = []; let content: Buffer | undefined = undefined; - if (zip.files.length == 0) { + if (zip.files.length === 0) { throw new BadRequestException("empty_zip") } - for (const file of zip.files) { - 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) - }); + 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("missing_metadata"); @@ -49,20 +55,30 @@ export async function processLearningObjectZip(filePath: string): Promise learningObject.attachments.add(it)); - + })); + attachmentEntities.forEach(it => { learningObject.attachments.add(it); }); return learningObject; } diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 64f2363d..bb450618 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -37,6 +37,28 @@ export abstract class BaseController { return response.data; } + /** + * Sends a POST-request with a form-data body with the given file. + * + * @param path Relative path in the api to send the request to. + * @param formFieldName The name of the form field in which the file should be. + * @param file The file to upload. + * @param queryParams The query parameters. + * @returns The response the POST request generated. + */ + protected async postFile(path: string, formFieldName: string, file: File, queryParams?: QueryParams): Promise { + const formData = new FormData(); + formData.append(formFieldName, file); + const response = await apiClient.post(this.absolutePathFor(path), formData, { + params: queryParams, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + BaseController.assertSuccessResponse(response) + return response.data; + } + protected async delete(path: string, queryParams?: QueryParams): Promise { const response = await apiClient.delete(this.absolutePathFor(path), { params: queryParams }); BaseController.assertSuccessResponse(response); diff --git a/frontend/src/controllers/learning-objects.ts b/frontend/src/controllers/learning-objects.ts index d62ba1f4..7239b88f 100644 --- a/frontend/src/controllers/learning-objects.ts +++ b/frontend/src/controllers/learning-objects.ts @@ -14,4 +14,12 @@ export class LearningObjectController extends BaseController { async getHTML(hruid: string, language: Language, version: number): Promise { return this.get(`/${hruid}/html`, { language, version }, "document"); } + + async getAllAdministratedBy(admin: string): Promise { + return this.get("/", { admin }); + } + + async upload(learningObjectZip: File): Promise { + return this.postFile("/", "learningObject", learningObjectZip); + } } diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index 35ed7ae4..5e7612e9 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -1,9 +1,10 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { useMutation, useQuery, useQueryClient, type UseMutationReturnType, type UseQueryReturnType } from "@tanstack/vue-query"; import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); @@ -24,15 +25,15 @@ export function useLearningObjectMetadataQuery( } export function useLearningObjectHTMLQuery( - hruid: MaybeRefOrGetter, - language: MaybeRefOrGetter, - version: MaybeRefOrGetter, + hruid: MaybeRefOrGetter, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], queryFn: async () => { const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; - return learningObjectController.getHTML(hruidVal, languageVal, versionVal); + return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!); }, enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), }); @@ -55,3 +56,25 @@ export function useLearningObjectListForPathQuery( enabled: () => Boolean(toValue(learningPath)), }); } + +export function useLearningObjectListForAdminQuery( + admin: MaybeRefOrGetter +): UseQueryReturnType { + 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 { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip), + onSuccess: async () => { await queryClient.invalidateQueries({queryKey: [LEARNING_OBJECT_KEY, "forAdmin"]}); } + }); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 359eab1a..c8a1ebc4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue"; import SingleTheme from "@/views/SingleTheme.vue"; import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import authService from "@/services/auth/auth-service"; +import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -114,6 +115,12 @@ const router = createRouter({ component: LearningPathSearchPage, meta: { requiresAuth: true }, }, + { + path: "my", + name: "OwnLearningContentPage", + component: OwnLearningContentPage, + meta: { requiresAuth: true } + }, { path: ":hruid/:language/:learningObjectHruid", name: "LearningPath", diff --git a/frontend/src/views/own-learning-content/LearningObjectUploadButton.vue b/frontend/src/views/own-learning-content/LearningObjectUploadButton.vue new file mode 100644 index 00000000..34211467 --- /dev/null +++ b/frontend/src/views/own-learning-content/LearningObjectUploadButton.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/views/own-learning-content/OwnLearningContentPage.vue b/frontend/src/views/own-learning-content/OwnLearningContentPage.vue index 85af7153..609c8433 100644 --- a/frontend/src/views/own-learning-content/OwnLearningContentPage.vue +++ b/frontend/src/views/own-learning-content/OwnLearningContentPage.vue @@ -1,11 +1,52 @@ diff --git a/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue b/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue index 85af7153..831f1cf0 100644 --- a/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue +++ b/frontend/src/views/own-learning-content/OwnLearningObjectsView.vue @@ -1,11 +1,80 @@ diff --git a/frontend/src/views/own-learning-content/OwnLearningPathsView.vue b/frontend/src/views/own-learning-content/OwnLearningPathsView.vue new file mode 100644 index 00000000..b199202e --- /dev/null +++ b/frontend/src/views/own-learning-content/OwnLearningPathsView.vue @@ -0,0 +1,9 @@ + + + + + From a7f90aace38437afc9c8966699bb2c7217b5e30b Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 12 May 2025 14:35:55 +0200 Subject: [PATCH 05/47] feat(backend): Endpoints voor het verwijderen van leerobjecten --- backend/src/controllers/learning-objects.ts | 19 +++++++++++++++++++ .../content/learning-object-repository.ts | 9 +++++++++ .../learning-object-service.ts | 9 +++++++++ 3 files changed, 37 insertions(+) diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index ba8fdef3..967ce355 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -92,3 +92,22 @@ export async function handlePostLearningObject(req: AuthenticatedRequest, res: R ); res.json(learningObject); } + +export async function handleDeleteLearningObject(req: AuthenticatedRequest, res: Response): Promise { + 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"); + } +} diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index 1dd7c9e0..889370d5 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -42,4 +42,13 @@ export class LearningObjectRepository extends DwengoEntityRepository { + const learningObject = await this.findByIdentifier(identifier); + if (learningObject) { + await this.em.removeAndFlush(learningObject); + } + return learningObject; + } + } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 0a229fde..4ada879b 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -10,6 +10,7 @@ import { import {getLearningObjectRepository, getTeacherRepository} from "../../data/repositories"; import {processLearningObjectZip} from "./learning-object-zip-processing-service"; import {LearningObject} from "../../entities/content/learning-object.entity"; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -83,6 +84,14 @@ const learningObjectService = { await learningObjectRepository.save(learningObject, {preventOverwrite: true}); return learningObject; + }, + + /** + * Deletes the learning object with the given identifier. + */ + async deleteLearningObject(id: LearningObjectIdentifier): Promise { + const learningObjectRepository = getLearningObjectRepository(); + return await learningObjectRepository.removeByIdentifier(id); } }; From 20c04370b5a9527f94e1b820fede51b6f7688318 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 12 May 2025 14:57:54 +0200 Subject: [PATCH 06/47] feat(backend): Bescherming van leerobject-manipulatie endpoints. Ook delete route voor leerobjecten toegevoegd. --- backend/src/middleware/auth/auth.ts | 13 ++++++++++--- .../auth/checks/learning-object-auth-checks.ts | 16 ++++++++++++++++ backend/src/routes/learning-objects.ts | 11 ++++++++++- .../learning-objects/learning-object-service.ts | 14 ++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 backend/src/middleware/auth/checks/learning-object-auth-checks.ts diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index 73a65b9a..2e1c3765 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -8,6 +8,7 @@ import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticationInfo } from './authentication-info.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; import { ForbiddenException } from '../../exceptions/forbidden-exception.js'; +import { RequestHandler } from 'express'; const JWKS_CACHE = true; const JWKS_RATE_LIMIT = true; @@ -115,11 +116,17 @@ export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates * to true. */ -export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) { - return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => { +export function authorize( + accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest) => boolean | Promise +): RequestHandler { + return async ( + req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { if (!req.auth) { throw new UnauthorizedException(); - } else if (!accessCondition(req.auth)) { + } else if (!(await accessCondition(req.auth, req))) { throw new ForbiddenException(); } else { next(); diff --git a/backend/src/middleware/auth/checks/learning-object-auth-checks.ts b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts new file mode 100644 index 00000000..31387198 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts @@ -0,0 +1,16 @@ +import { Language } from "@dwengo-1/common/util/language"; +import learningObjectService from "../../../services/learning-objects/learning-object-service"; +import { authorize } from "../auth"; +import { AuthenticatedRequest } from "../authenticated-request"; +import { AuthenticationInfo } from "../authentication-info"; + +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 auth.username in admins; +}); diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index 254d7ebc..46339ce5 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -4,12 +4,15 @@ import { getAttachment, getLearningObject, getLearningObjectHTML, + handleDeleteLearningObject, handlePostLearningObject } from '../controllers/learning-objects.js'; import submissionRoutes from './submissions.js'; import questionRoutes from './questions.js'; import fileUpload from "express-fileupload"; +import { teachersOnly } from '../middleware/auth/auth.js'; +import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; const router = express.Router(); @@ -25,7 +28,7 @@ const router = express.Router(); // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie router.get('/', getAllLearningObjects); -router.post('/', fileUpload({useTempFiles: true}), handlePostLearningObject) +router.post('/', teachersOnly, fileUpload({useTempFiles: true}), handlePostLearningObject) // Parameter: hruid of learning object // Query: language @@ -33,6 +36,12 @@ router.post('/', fileUpload({useTempFiles: true}), handlePostLearningObject) // Example: http://localhost:3000/learningObject/un_ai7 router.get('/:hruid', getLearningObject); +// Parameter: hruid of learning object +// Query: language +// Route to delete a learning object based on its hruid. +// Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1 +router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject) + router.use('/:hruid/submissions', submissionRoutes); router.use('/:hruid/:version/questions', questionRoutes); diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 4ada879b..f70b88b2 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -11,6 +11,7 @@ import {getLearningObjectRepository, getTeacherRepository} from "../../data/repo import {processLearningObjectZip} from "./learning-object-zip-processing-service"; import {LearningObject} from "../../entities/content/learning-object.entity"; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { NotFoundException } from '../../exceptions/not-found-exception.js'; function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -92,6 +93,19 @@ const learningObjectService = { async deleteLearningObject(id: LearningObjectIdentifier): Promise { 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 { + const learningObjectRepo = getLearningObjectRepository(); + const learningObject = await learningObjectRepo.findByIdentifier(id); + if (!learningObject) { + throw new NotFoundException("The specified learning object does not exist."); + } + return learningObject.admins.map(admin => admin.username); } }; From 30ca3b70ded19467c1d91769f5338b7b742a18ef Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 12 May 2025 16:11:08 +0200 Subject: [PATCH 07/47] feat(backend): PUSH, PUT en DELETE endpoints voor leerpaden aangemaakt. --- backend/src/controllers/learning-paths.ts | 118 ++++++++++++------ .../data/content/learning-path-repository.ts | 27 ++++ .../auth/checks/learning-path-auth-checks.ts | 12 ++ backend/src/routes/learning-paths.ts | 8 +- .../database-learning-path-provider.ts | 9 ++ .../dwengo-api-learning-path-provider.ts | 4 + .../learning-paths/learning-path-provider.ts | 5 + .../learning-paths/learning-path-service.ts | 47 ++++++- 8 files changed, 186 insertions(+), 44 deletions(-) create mode 100644 backend/src/middleware/auth/checks/learning-path-auth-checks.ts diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 1bd3f2b1..9a03e681 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -7,51 +7,89 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { Group } from '../entities/assignments/group.entity.js'; import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; +import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; +import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { getTeacher } from '../services/teachers.js'; /** * Fetch learning paths based on query parameters. */ export async function getLearningPaths(req: Request, res: Response): Promise { - const hruids = req.query.hruid; - const themeKey = req.query.theme as string; - const searchQuery = req.query.search as string; - const language = (req.query.language as string) || FALLBACK_LANG; - - const forGroupNo = req.query.forGroup as string; - const assignmentNo = req.query.assignmentNo as string; - const classId = req.query.classId as string; - - let forGroup: Group | undefined; - - if (forGroupNo) { - if (!assignmentNo || !classId) { - throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); - } - const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); - if (assignment) { - forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; - } - } - - let hruidList; - - if (hruids) { - hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; - } else if (themeKey) { - const theme = themes.find((t) => t.title === themeKey); - if (theme) { - hruidList = theme.hruids; - } else { - throw new NotFoundException(`Theme "${themeKey}" not found.`); - } - } else if (searchQuery) { - const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); - res.json(searchResults); - return; + const admin = req.query.admin; + if (admin) { + const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); + res.json(paths); } else { - hruidList = themes.flatMap((theme) => theme.hruids); - } + const hruids = req.query.hruid; + const themeKey = req.query.theme as string; + const searchQuery = req.query.search as string; + const language = (req.query.language as string) || FALLBACK_LANG; - const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); - res.json(learningPaths.data); + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; + + let forGroup: Group | undefined; + + if (forGroupNo) { + if (!assignmentNo || !classId) { + throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); + } + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); + if (assignment) { + forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; + } + } + + let hruidList; + + if (hruids) { + hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; + } else if (themeKey) { + const theme = themes.find((t) => t.title === themeKey); + if (theme) { + hruidList = theme.hruids; + } else { + throw new NotFoundException(`Theme "${themeKey}" not found.`); + } + } else if (searchQuery) { + const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); + res.json(searchResults); + return; + } else { + hruidList = themes.flatMap((theme) => theme.hruids); + } + + const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); + res.json(learningPaths.data); + } +} + +function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise { + return async (req, res) => { + const path: LearningPath = req.body; + if (isPut) { + if (req.params.hruid !== path.hruid || req.params.language !== path.language) { + throw new BadRequestException("id_not_matching_query_params"); + } + } + const teacher = await getTeacher(req.auth!.username); + res.json(await learningPathService.createNewLearningPath(path, [teacher], isPut)); + } +} + +export const postLearningPath = postOrPutLearningPath(false); +export const putLearningPath = postOrPutLearningPath(true); + +export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise { + const id: LearningPathIdentifier = { + hruid: req.params.hruid, + language: req.params.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."); + } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 67f08a03..beb0abec 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -28,6 +28,21 @@ export class LearningPathRepository extends DwengoEntityRepository }); } + /** + * Returns all learning paths which have the user with the given username as an administrator. + */ + public async findAllByAdminUsername(adminUsername: string): Promise { + return this.findAll({ + where: { + admins: { + $contains: { + username: adminUsername + } + } + } + }); + } + public createNode(nodeData: RequiredEntityData): LearningPathNode { return this.em.create(LearningPathNode, nodeData); } @@ -50,4 +65,16 @@ export class LearningPathRepository extends DwengoEntityRepository await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); } + + /** + * Deletes the learning path with the given hruid and language. + * @returns the deleted learning path or null if it was not found. + */ + public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise { + const path = await this.findByHruidAndLanguage(hruid, language); + if (path) { + await this.em.removeAndFlush(path); + } + return path; + } } diff --git a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts new file mode 100644 index 00000000..6c73e22e --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -0,0 +1,12 @@ +import learningPathService from "../../../services/learning-paths/learning-path-service"; +import { authorize } from "../auth"; +import { AuthenticatedRequest } from "../authenticated-request"; +import { AuthenticationInfo } from "../authentication-info"; + +export const onlyAdminsForLearningPath = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const adminsForLearningPath = learningPathService.getAdmins({ + hruid: req.body.hruid, + language: req.body.language + }); + return adminsForLearningPath && auth.username in adminsForLearningPath; +}); diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index efe17312..b2e67d57 100644 --- a/backend/src/routes/learning-paths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,7 @@ import express from 'express'; -import { getLearningPaths } from '../controllers/learning-paths.js'; +import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; +import { teachersOnly } from '../middleware/auth/auth.js'; +import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; const router = express.Router(); @@ -23,5 +25,9 @@ const router = express.Router(); // Example: http://localhost:3000/learningPath?theme=kiks router.get('/', getLearningPaths); +router.post('/', teachersOnly, postLearningPath) + +router.put('/:hruid/:language', onlyAdminsForLearningObject, putLearningPath); +router.delete('/:hruid/:language', onlyAdminsForLearningObject, deleteLearningPath); export default router; diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index fe05dda1..ac525831 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -198,6 +198,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 { + 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. */ diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts index 110cd570..f379c049 100644 --- a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -45,6 +45,10 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); return searchResults ?? []; }, + + async getLearningPathsAdministratedBy(_adminUsername: string) { + return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user. + }, }; export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 086777bd..0cf507ca 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -15,4 +15,9 @@ export interface LearningPathProvider { * Search learning paths in the data source using the given search string. */ searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise; + + /** + * Get all learning paths which have the teacher with the given user as an administrator. + */ + getLearningPathsAdministratedBy(adminUsername: string): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index b20d8f97..53c084fd 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -1,7 +1,7 @@ import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; import databaseLearningPathProvider from './database-learning-path-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; -import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { Language } from '@dwengo-1/common/util/language'; import { Group } from '../../entities/assignments/group.entity.js'; import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; @@ -12,6 +12,7 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { mapToTeacher } from '../../interfaces/teacher.js'; import { Collection } from '@mikro-orm/core'; +import { NotFoundException } from '../../exceptions/not-found-exception.js'; const userContentPrefix = getEnvVar(envVars.UserContentPrefix); const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; @@ -105,6 +106,16 @@ const learningPathService = { }; }, + /** + * Fetch the learning paths administrated by the teacher with the given username. + */ + async getLearningPathsAdministratedBy(adminUsername: string): Promise { + const providerResponses = await Promise.all( + allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername)) + ); + return providerResponses.flat(); + }, + /** * Search learning paths in the data source using the given search string. */ @@ -119,12 +130,42 @@ const learningPathService = { * Add a new learning path to the database. * @param dto Learning path DTO from which the learning path will be created. * @param admins Teachers who should become an admin of the learning path. + * @param allowReplace If this is set to true and there is already a learning path with the same identifier, it is replaced. + * @returns the created learning path. */ - async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[], allowReplace = false): Promise { const repo = getLearningPathRepository(); const path = mapToLearningPath(dto, admins); - await repo.save(path, { preventOverwrite: true }); + await repo.save(path, { preventOverwrite: allowReplace }); + 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 { + 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 { + 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); + } }; export default learningPathService; From a6e0c4bbd67d2dc10d33560d6eb5f25a3d756c81 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 12 May 2025 21:45:31 +0200 Subject: [PATCH 08/47] feat(frontend): Frontend-controllers voor het beheren van leerpaden & verwijderen van leerobjecten aangemaakt --- frontend/src/controllers/learning-objects.ts | 4 ++++ frontend/src/controllers/learning-paths.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/frontend/src/controllers/learning-objects.ts b/frontend/src/controllers/learning-objects.ts index 7239b88f..a9ecf22f 100644 --- a/frontend/src/controllers/learning-objects.ts +++ b/frontend/src/controllers/learning-objects.ts @@ -22,4 +22,8 @@ export class LearningObjectController extends BaseController { async upload(learningObjectZip: File): Promise { return this.postFile("/", "learningObject", learningObjectZip); } + + async deleteLearningObject(hruid: string, language: Language, version: number): Promise { + return this.delete(`/${hruid}`, { language, version }); + } } diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index bad54286..697826bc 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -36,4 +36,21 @@ export class LearningPathController extends BaseController { const dtos = await this.get("/", query); return dtos.map((dto) => LearningPath.fromDTO(dto)); } + + async getAllByAdmin(admin: string): Promise { + const dtos = await this.get("/", { admin }); + return dtos.map((dto) => LearningPath.fromDTO(dto)); + } + + async postLearningPath(learningPath: LearningPathDTO): Promise { + return await this.post("/", learningPath); + } + + async putLearningPath(learningPath: LearningPathDTO): Promise { + return await this.put(`/${learningPath.hruid}/${learningPath.language}`, learningPath); + } + + async deleteLearningPath(hruid: string, language: string): Promise { + return await this.delete(`/${hruid}/${language}`); + } } From 69292885545527ea3ed4a0339ff11ab49637f376 Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Mon, 12 May 2025 21:51:26 +0200 Subject: [PATCH 09/47] feat(frontend): LearningObjectDeletionMutation toegevoegd --- frontend/src/queries/learning-objects.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index 5e7612e9..e06f21c5 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -5,6 +5,7 @@ import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { AxiosError } from "axios"; +import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); @@ -78,3 +79,12 @@ export function useUploadLearningObjectMutation(): UseMutationReturnType { await queryClient.invalidateQueries({queryKey: [LEARNING_OBJECT_KEY, "forAdmin"]}); } }); } + +export function useDeleteLearningObjectMutation(): UseMutationReturnType { + 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"]}); } + }); +} From 1a768fedccaa0ac0121e3fc6e5d62823fc1b471e Mon Sep 17 00:00:00 2001 From: Gerald Schmittinger Date: Tue, 13 May 2025 01:02:53 +0200 Subject: [PATCH 10/47] fix(backend): Bugs omtrent leerpad-endpoints opgelost --- backend/src/data/content/learning-object-repository.ts | 2 +- backend/src/data/content/learning-path-repository.ts | 4 +--- backend/src/entities/content/learning-object.entity.ts | 2 ++ .../middleware/auth/checks/learning-object-auth-checks.ts | 2 +- .../middleware/auth/checks/learning-path-auth-checks.ts | 6 +++--- .../learning-object-zip-processing-service.ts | 8 ++++---- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index 889370d5..a862bfc2 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -12,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository return this.findAll({ where: { admins: { - $contains: { - username: adminUsername - } + username: adminUsername } } }); diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index 825bf744..59593c9a 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -1,5 +1,6 @@ import { ArrayType, + Cascade, Collection, Embedded, Entity, @@ -93,6 +94,7 @@ export class LearningObject { @OneToMany({ entity: () => Attachment, mappedBy: 'learningObject', + cascade: [Cascade.ALL] }) attachments: Collection = new Collection(this); diff --git a/backend/src/middleware/auth/checks/learning-object-auth-checks.ts b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts index 31387198..7ef91947 100644 --- a/backend/src/middleware/auth/checks/learning-object-auth-checks.ts +++ b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts @@ -12,5 +12,5 @@ export const onlyAdminsForLearningObject = authorize(async (auth: Authentication language: language as Language, version: parseInt(version as string) }); - return auth.username in admins; + return admins.includes(auth.username); }); diff --git a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts index 6c73e22e..64e51416 100644 --- a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -3,10 +3,10 @@ import { authorize } from "../auth"; import { AuthenticatedRequest } from "../authenticated-request"; import { AuthenticationInfo } from "../authentication-info"; -export const onlyAdminsForLearningPath = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => { - const adminsForLearningPath = learningPathService.getAdmins({ +export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const adminsForLearningPath = await learningPathService.getAdmins({ hruid: req.body.hruid, language: req.body.language }); - return adminsForLearningPath && auth.username in adminsForLearningPath; + return adminsForLearningPath && adminsForLearningPath.includes(auth.username); }); diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts index 213c3f17..a502aecd 100644 --- a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -18,7 +18,7 @@ export async function processLearningObjectZip(filePath: string): Promise Date: Tue, 13 May 2025 01:03:55 +0200 Subject: [PATCH 11/47] feat(frontend): Heel ruwe eerste versie van leerpadbeheerpagina toegevoegd --- frontend/package.json | 1 + frontend/src/components/MenuBar.vue | 3 + frontend/src/controllers/learning-paths.ts | 5 +- frontend/src/i18n/locale/en.json | 15 +- frontend/src/main.ts | 10 + frontend/src/queries/learning-objects.ts | 1 - frontend/src/queries/learning-paths.ts | 44 +- .../OwnLearningContentPage.vue | 20 +- .../OwnLearningPathsView.vue | 9 - .../LearningObjectPreviewCard.vue | 50 + .../LearningObjectUploadButton.vue | 9 +- .../OwnLearningObjectsView.vue | 29 +- .../LearningPathPreviewCard.vue | 62 + .../learning-paths/OwnLearningPathsView.vue | 60 + package-lock.json | 6234 ++--------------- 15 files changed, 732 insertions(+), 5820 deletions(-) delete mode 100644 frontend/src/views/own-learning-content/OwnLearningPathsView.vue create mode 100644 frontend/src/views/own-learning-content/learning-objects/LearningObjectPreviewCard.vue rename frontend/src/views/own-learning-content/{ => learning-objects}/LearningObjectUploadButton.vue (85%) rename frontend/src/views/own-learning-content/{ => learning-objects}/OwnLearningObjectsView.vue (59%) create mode 100644 frontend/src/views/own-learning-content/learning-paths/LearningPathPreviewCard.vue create mode 100644 frontend/src/views/own-learning-content/learning-paths/OwnLearningPathsView.vue diff --git a/frontend/package.json b/frontend/package.json index 0826edae..58287e14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@tanstack/vue-query": "^5.69.0", "@vueuse/core": "^13.1.0", "axios": "^1.8.2", + "json-editor-vue": "^0.18.1", "oidc-client-ts": "^3.1.0", "rollup": "^4.40.0", "uuid": "^11.1.0", diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index a58be2f8..d8eccacb 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -7,8 +7,10 @@ // Import assets import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; +import { useLocale } from "vuetify"; const { t, locale } = useI18n(); + const { current: vuetifyLocale } = useLocale(); const role = auth.authState.activeRole; const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable @@ -31,6 +33,7 @@ // Logic to change the language of the website to the selected language function changeLanguage(langCode: string): void { locale.value = langCode; + vuetifyLocale.value = langCode; localStorage.setItem("user-lang", langCode); } diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index 697826bc..f8b82bef 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -37,9 +37,8 @@ export class LearningPathController extends BaseController { return dtos.map((dto) => LearningPath.fromDTO(dto)); } - async getAllByAdmin(admin: string): Promise { - const dtos = await this.get("/", { admin }); - return dtos.map((dto) => LearningPath.fromDTO(dto)); + async getAllByAdminRaw(admin: string): Promise { + return await this.get("/", { admin }); } async postLearningPath(learningPath: LearningPathDTO): Promise { diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index e4042d09..47acd255 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -121,5 +121,18 @@ "invite": "invite", "assignmentIndicator": "ASSIGNMENT", "searchAllLearningPathsTitle": "Search all learning paths", - "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths." + "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.", + "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." } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b5315634..f6f90716 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,15 +7,20 @@ import * as components from "vuetify/components"; import * as directives from "vuetify/directives"; import i18n from "./i18n/i18n.ts"; +// JSON-editor +import JsonEditorVue from 'json-editor-vue'; + // Components import App from "./App.vue"; import router from "./router"; import { aliases, mdi } from "vuetify/iconsets/mdi"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; +import { de, en, fr, nl } from "vuetify/locale"; const app = createApp(App); app.use(router); +app.use(JsonEditorVue, {}) const link = document.createElement("link"); link.rel = "stylesheet"; @@ -32,6 +37,11 @@ const vuetify = createVuetify({ mdi, }, }, + locale: { + locale: i18n.global.locale, + fallback: 'en', + messages: { nl, en, de, fr } + } }); const queryClient = new QueryClient({ diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index e06f21c5..2ed8bc52 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -5,7 +5,6 @@ import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { AxiosError } from "axios"; -import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content"; export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts index 1f088c9d..d24b6fc5 100644 --- a/frontend/src/queries/learning-paths.ts +++ b/frontend/src/queries/learning-paths.ts @@ -1,8 +1,10 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { useMutation, useQuery, useQueryClient, type UseMutationReturnType, type UseQueryReturnType } from "@tanstack/vue-query"; import { getLearningPathController } from "@/controllers/controllers"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; +import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto"; export const LEARNING_PATH_KEY = "learningPath"; const learningPathController = getLearningPathController(); @@ -32,6 +34,46 @@ export function useGetAllLearningPathsByThemeQuery( }); } +export function useGetAllLearningPathsByAdminQuery( + admin: MaybeRefOrGetter +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_PATH_KEY, "getAllByAdmin", admin], + queryFn: async () => learningPathController.getAllByAdminRaw(toValue(admin)!), + enabled: () => Boolean(toValue(admin)) + }); +} + +export function usePostLearningPathMutation(): + UseMutationReturnType { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.postLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }) + }); +} + +export function usePutLearningPathMutation(): + UseMutationReturnType { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.putLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }) + }); +} + +export function useDeleteLearningPathMutation(): + UseMutationReturnType { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language }) => learningPathController.deleteLearningPath(hruid, language), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }) + }); +} + export function useSearchLearningPathQuery( query: MaybeRefOrGetter, language: MaybeRefOrGetter, diff --git a/frontend/src/views/own-learning-content/OwnLearningContentPage.vue b/frontend/src/views/own-learning-content/OwnLearningContentPage.vue index 609c8433..02a2e90d 100644 --- a/frontend/src/views/own-learning-content/OwnLearningContentPage.vue +++ b/frontend/src/views/own-learning-content/OwnLearningContentPage.vue @@ -1,18 +1,23 @@ @@ -34,7 +39,12 @@ import { useI18n } from "vue-i18n"; - + + + @@ -45,8 +55,10 @@ import { useI18n } from "vue-i18n"; display: flex; flex-direction: column; height: 100%; + padding: 20px 30px; } .main-content { - flex: 1; + flex: 1 1; + height: 100%; } diff --git a/frontend/src/views/own-learning-content/OwnLearningPathsView.vue b/frontend/src/views/own-learning-content/OwnLearningPathsView.vue deleted file mode 100644 index b199202e..00000000 --- a/frontend/src/views/own-learning-content/OwnLearningPathsView.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/frontend/src/views/own-learning-content/learning-objects/LearningObjectPreviewCard.vue b/frontend/src/views/own-learning-content/learning-objects/LearningObjectPreviewCard.vue new file mode 100644 index 00000000..3f475863 --- /dev/null +++ b/frontend/src/views/own-learning-content/learning-objects/LearningObjectPreviewCard.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/views/own-learning-content/LearningObjectUploadButton.vue b/frontend/src/views/own-learning-content/learning-objects/LearningObjectUploadButton.vue similarity index 85% rename from frontend/src/views/own-learning-content/LearningObjectUploadButton.vue rename to frontend/src/views/own-learning-content/learning-objects/LearningObjectUploadButton.vue index 34211467..4507401b 100644 --- a/frontend/src/views/own-learning-content/LearningObjectUploadButton.vue +++ b/frontend/src/views/own-learning-content/learning-objects/LearningObjectUploadButton.vue @@ -32,17 +32,14 @@