diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5fbca23a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules/ +**/dist +.git +npm-debug.log +.coverage +.coverage.* +.env \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 8a6629f0..05b96755 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -24,6 +24,14 @@ Een duidelijke, beknopte beschrijving van wat je verwacht dat er gebeurt. Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt. **Extra context** -Voeg extra context over het probleem toe. Was je ergens bijzonder mee bezig of naar op zoek? +Was je ergens bijzonder mee bezig of naar op zoek? Welke documentatie of links heb je (al) geraadpleegd? -- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index b99513ab..dd1ed988 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -6,7 +6,7 @@ labels: enhancement assignees: '' --- -**Is your feature request related to a problem? Please describe.** +**Is jouw feature request gerelateerd tot een probleem? Beschrijf.** Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter? **Beschrijf de oplossing die je zou willen** @@ -15,4 +15,11 @@ Een duidelijke, beknopte beschrijving van wat je zou willen dat er gebeurt. **Extra context** Extra context of screenshots bij de feature. -- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... + diff --git a/README.md b/README.md index db5b63a4..dc09bbfc 100644 --- a/README.md +++ b/README.md @@ -9,34 +9,43 @@ Figma Projectopgave

- - Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. ## Installatie +Om de applicatie in te stellen voor een productieomgeving, volg +de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). + +Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien. + ### Quick start -1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)). +1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) + en [Docker Compose](https://docs.docker.com/compose/)). 2. Clone deze repository. -3. Voer `docker compose up` uit in de root van de repository. +3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar + nodig. +4. Voer `docker compose up` uit in de root van de repository. +5. Optioneel: Configureer de applicatie aan de hand van + de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). ```bash docker compose version git clone https://github.com/SELab-2/Dwengo-1.git -cd Dwengo-1 +cd Dwengo-1/backend +cp .env.example .env +# Pas .env aan +nano .env +cd .. docker compose up +# Configureer de applicatie ``` ### Handmatige installatie -Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) en [backend](./backend/README.md). +Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) +en [backend](./backend/README.md). ## Architectuur @@ -46,9 +55,33 @@ De tech-stack bestaat uit: - **Frontend**: TypeScript + Vue.js + Vuetify - **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL +- **Identity provider**: Keycloak -Voor meer informatie over de keuze van deze tech-stack, zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Design-keuzes). +Voor meer informatie over de keuze van deze tech-stack, +zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes). + +## Testen + +Voer volgende commando's uit om de te testen: + +``` +npm run test:unit +``` ## Bijdragen aan Dwengo-1 Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1. + +Deze rocksterren hebben bijgedragen aan Dwengo-1: + +| Naam | Functie | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| [
Adriaan Jacquet](https://github.com/WhisperinCheetah) | Backend Lead | +| [
Francisco Gabriel Van Langenhove](https://github.com/Gabriellvl) | Team Lead | +| [
Gerald Schmittinger](https://github.com/geraldschmittinger) | Database Administrator | +| [
Joyelle Ndagijimana](https://github.com/joyelle436) | Frontend Lead | +| [
Laure Jablonski](https://github.com/laurejablonski) | Documentatie- en Test Lead | +| [
Tibo De Peuter](https://github.com/tdpeuter) | Technische Lead | +| [
Timo De Meyst](https://github.com/kloep1) | System Administrator | + +En in de toekomst misschien jij ook? diff --git a/assets/img/keycloak.png b/assets/img/keycloak.png new file mode 100644 index 00000000..6a79a7a2 Binary files /dev/null and b/assets/img/keycloak.png differ diff --git a/backend/.env.development.example b/backend/.env.development.example index 247ff054..466e1b7b 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -1,10 +1,16 @@ -DWENGO_PORT=3000 +# +# Basic configuration +# + +DWENGO_PORT=3000 # The port the backend will listen on DWENGO_DB_HOST=localhost DWENGO_DB_PORT=5431 DWENGO_DB_USERNAME=postgres DWENGO_DB_PASSWORD=postgres DWENGO_DB_UPDATE=true +# Auth + DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs @@ -14,3 +20,9 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/ # Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 + +# +# Advanced configuration +# + +# LOKI_HOST=http://localhost:9001 # The address of the Loki instance, used for logging diff --git a/backend/.env.example b/backend/.env.example index fd193c89..68cef35d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,10 +2,9 @@ # Basic configuration # -# The port the backend will listen on -DWENGO_PORT=3000 +DWENGO_PORT=3000 # The port the backend will listen on DWENGO_DB_HOST=domain-or-ip-of-database -DWENGO_DB_PORT=5432 +DWENGO_DB_PORT=5431 # Change this to the actual credentials of the user Dwengo should use in the backend DWENGO_DB_USERNAME=postgres @@ -24,9 +23,5 @@ DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs -# -# Advanced configuration -# - # The address of the Lokiinstance, used for logging # LOKI_HOST=http://localhost:3102 diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 00000000..390409d1 --- /dev/null +++ b/backend/.env.production.example @@ -0,0 +1,28 @@ +DWENGO_PORT=3000 # The port the backend will listen on +DWENGO_DB_HOST=db # Name of the database container +DWENGO_DB_PORT=5431 + +# Change this to the actual credentials of the user Dwengo should use in the backend +DWENGO_DB_NAME=postgres +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres + +# Set this to true when the database scheme needs to be updated. In that case, take a backup first. +DWENGO_DB_UPDATE=false + +# Data for the identity provider via which the students authenticate. +DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container +# Data for the identity provider via which the teachers authenticate. +DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container + +# +# Advanced configuration +# + +# Logging and monitoring + +# LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging diff --git a/backend/.env.test b/backend/.env.test.example similarity index 100% rename from backend/.env.test rename to backend/.env.test.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..5f26847c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +FROM node:22 AS build-stage + +WORKDIR /app + +# Install dependencies + +COPY package*.json ./ +COPY backend/package.json ./backend/ + +RUN npm install --silent + +# Build the backend + +# Root tsconfig.json +COPY tsconfig.json ./ + +WORKDIR /app/backend + +COPY backend ./ + +RUN npm run build + +FROM node:22 AS production-stage + +WORKDIR /app + +COPY package-lock.json backend/package.json ./ + +RUN npm install --silent --only=production + +COPY --from=build-stage /app/backend/dist ./dist/ + +EXPOSE 3000 + +CMD ["node", "--env-file=.env", "dist/app.js"] diff --git a/backend/README.md b/backend/README.md index 76bc8eae..442cea82 100644 --- a/backend/README.md +++ b/backend/README.md @@ -20,3 +20,18 @@ npm run dev npm run build npm run start ``` + +### Tests + +Voer volgend commando uit om de unit tests uit te voeren: + +``` +npm run test:unit +``` + +## Keycloak configuratie + +Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. + +Voor productie is het ten sterkste aangeraden om keycloak manueel te configureren. +Voor meer informatie, zie de [administrator-handleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#installatie-en-server-configuratie). diff --git a/backend/config.ts b/backend/config.ts deleted file mode 100644 index 8fd8ec3f..00000000 --- a/backend/config.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Can be placed in dotenv but found it redundant - -// Import dotenv from "dotenv"; - -// Load .env file -// Dotenv.config(); - -export const DWENGO_API_BASE = 'https://dwengo.org/backend/api'; - -export const FALLBACK_LANG = 'nl'; diff --git a/backend/eslint.config.ts b/backend/eslint.config.ts index f5f225b2..6b696021 100644 --- a/backend/eslint.config.ts +++ b/backend/eslint.config.ts @@ -8,4 +8,14 @@ export default [ globals: globals.node, }, }, + + { + files: ['tests/**/*.ts'], + languageOptions: { + globals: globals.node, + }, + rules: { + 'no-console': 'off', + }, + }, ]; diff --git a/backend/package.json b/backend/package.json index fafb24f4..9d7e886a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,27 +5,34 @@ "private": true, "type": "module", "scripts": { - "build": "NODE_ENV=production tsc --project tsconfig.json", - "dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", - "start": "NODE_ENV=production node --env-file=.env dist/app.js", + "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", + "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", + "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", "format": "prettier --write src/", "format-check": "prettier --check src/", "lint": "eslint . --fix", "test:unit": "vitest" }, "dependencies": { - "@mikro-orm/core": "6.4.6", - "@mikro-orm/postgresql": "6.4.6", - "@mikro-orm/reflection": "6.4.6", - "@mikro-orm/sqlite": "6.4.6", - "axios": "^1.8.1", + "@mikro-orm/core": "6.4.9", + "@mikro-orm/knex": "6.4.9", + "@mikro-orm/postgresql": "6.4.9", + "@mikro-orm/reflection": "6.4.9", + "@mikro-orm/sqlite": "6.4.9", + "axios": "^1.8.2", + "cors": "^2.8.5", + "cross": "^1.0.0", + "cross-env": "^7.0.3", "dotenv": "^16.4.7", "express": "^5.0.1", "express-jwt": "^8.5.1", - "jwks-rsa": "^3.1.0", + "gift-pegjs": "^1.0.2", + "isomorphic-dompurify": "^2.22.0", "js-yaml": "^4.1.0", - "cors": "^2.8.5", + "jsonpath-plus": "^10.3.0", + "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", + "marked": "^15.0.7", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", @@ -33,13 +40,13 @@ "winston-loki": "^6.1.3" }, "devDependencies": { - "@types/js-yaml": "^4.0.9", - "@mikro-orm/cli": "^6.4.6", + "@mikro-orm/cli": "6.4.9", + "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", "@types/swagger-ui-express": "^4.1.8", - "@types/cors": "^2.8.17", "globals": "^15.15.0", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/backend/src/app.ts b/backend/src/app.ts index a80980d2..1f518a4a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,25 +1,12 @@ -import express, { Express, Response } from 'express'; +import express, { Express } from 'express'; import { initORM } from './orm.js'; - -import themeRoutes from './routes/themes.js'; -import learningPathRoutes from './routes/learningPaths.js'; -import learningObjectRoutes from './routes/learningObjects.js'; - -import studentRouter from './routes/student.js'; -import groupRouter from './routes/group.js'; -import assignmentRouter from './routes/assignment.js'; -import submissionRouter from './routes/submission.js'; -import classRouter from './routes/class.js'; -import questionRouter from './routes/question.js'; -import authRouter from './routes/auth.js'; import { authenticateUser } from './middleware/auth/auth.js'; import cors from './middleware/cors.js'; import { getLogger, Logger } from './logging/initalize.js'; import { responseTimeLogger } from './logging/responseTimeLogger.js'; import responseTime from 'response-time'; import { EnvVars, getNumericEnvVar } from './util/envvars.js'; -import swaggerMiddleware from './swagger'; -import swaggerUi from 'swagger-ui-express'; +import apiRouter from './routes/router.js'; const logger: Logger = getLogger(); @@ -32,41 +19,13 @@ app.use(authenticateUser); // Add response time logging app.use(responseTime(responseTimeLogger)); -// TODO Replace with Express routes -app.get('/', (_, res: Response) => { - logger.debug('GET /'); - res.json({ - message: 'Hello Dwengo!🚀', - }); -}); - -// Routes -app.use('/student', studentRouter /* #swagger.tags = ['Student'] */); -app.use('/group', groupRouter /* #swagger.tags = ['Group'] */); -app.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); -app.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); -app.use('/class', classRouter /* #swagger.tags = ['Class'] */); -app.use('/question', questionRouter /* #swagger.tags = ['Question'] */); -app.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); -app.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); - -app.use( - '/learningPath', - learningPathRoutes /* #swagger.tags = ['Learning Path'] */ -); -app.use( - '/learningObject', - learningObjectRoutes /* #swagger.tags = ['Learning Object'] */ -); - -// Swagger UI for API documentation -app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); +app.get('/api', apiRouter); async function startServer() { await initORM(); app.listen(port, () => { - logger.info(`Server is running at http://localhost:${port}`); + logger.info(`Server is running at http://localhost:${port}/api`); }); } diff --git a/backend/src/config.ts b/backend/src/config.ts index b972a1bd..6cf388cc 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,12 +1,9 @@ -export const FALLBACK_LANG: string = 'nl'; +import { EnvVars, getEnvVar } from './util/envvars.js'; // API - -export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api'; +export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); +export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); // Logging - -export const LOG_LEVEL: string = - 'development' === process.env.NODE_ENV ? 'debug' : 'info'; -export const LOKI_HOST: string = - process.env.LOKI_HOST || 'http://localhost:3102'; +export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; +export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102'; diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts new file mode 100644 index 00000000..455a4006 --- /dev/null +++ b/backend/src/controllers/learning-objects.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { FALLBACK_LANG } from '../config.js'; +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js'; +import learningObjectService from '../services/learning-objects/learning-object-service.js'; +import { EnvVars, getEnvVar } from '../util/envvars.js'; +import { Language } from '../entities/content/language.js'; +import { BadRequestException } from '../exceptions.js'; +import attachmentService from '../services/learning-objects/attachment-service.js'; +import { NotFoundError } from '@mikro-orm/core'; + +function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { + if (!req.params.hruid) { + throw new BadRequestException('HRUID is required.'); + } + return { + hruid: req.params.hruid as string, + language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language, + version: parseInt(req.query.version as string), + }; +} + +function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { + if (!req.query.hruid) { + throw new BadRequestException('HRUID is required.'); + } + return { + hruid: req.params.hruid as string, + language: (req.query.language as Language) || FALLBACK_LANG, + }; +} + +export async function getAllLearningObjects(req: Request, res: Response): Promise { + 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); +} + +export async function getLearningObject(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); + res.json(learningObject); +} + +export async function getLearningObjectHTML(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId); + res.send(learningObject); +} + +export async function getAttachment(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + const name = req.params.attachmentName; + const attachment = await attachmentService.getAttachment(learningObjectId, name); + + if (!attachment) { + throw new NotFoundError(`Attachment ${name} not found`); + } + res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); +} diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts new file mode 100644 index 00000000..37f92d91 --- /dev/null +++ b/backend/src/controllers/learning-paths.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import { themes } from '../data/themes.js'; +import { FALLBACK_LANG } from '../config.js'; +import learningPathService from '../services/learning-paths/learning-path-service.js'; +import { BadRequestException, NotFoundException } from '../exceptions.js'; +import { Language } from '../entities/content/language.js'; +import { + PersonalizationTarget, + personalizedForGroup, + personalizedForStudent, +} from '../services/learning-paths/learning-path-personalization-util.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 forStudent = req.query.forStudent as string; + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; + + let personalizationTarget: PersonalizationTarget | undefined; + + if (forStudent) { + personalizationTarget = await personalizedForStudent(forStudent); + } else if (forGroupNo) { + if (!assignmentNo || !classId) { + throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); + } + personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo)); + } + + 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, personalizationTarget); + res.json(searchResults); + return; + } else { + hruidList = themes.flatMap((theme) => theme.hruids); + } + + const learningPaths = await learningPathService.fetchLearningPaths( + hruidList, + language as Language, + `HRUIDs: ${hruidList.join(', ')}`, + personalizationTarget + ); + res.json(learningPaths.data); +} diff --git a/backend/src/controllers/learningObjects.ts b/backend/src/controllers/learningObjects.ts index 6fde1208..e69de29b 100644 --- a/backend/src/controllers/learningObjects.ts +++ b/backend/src/controllers/learningObjects.ts @@ -1,61 +0,0 @@ -import { Request, Response } from 'express'; -import { - getLearningObjectById, - getLearningObjectIdsFromPath, - getLearningObjectsFromPath, -} from '../services/learningObjects.js'; -import { FALLBACK_LANG } from '../config.js'; -import { FilteredLearningObject } from '../interfaces/learningPath.js'; -import { getLogger } from '../logging/initalize.js'; - -export async function getAllLearningObjects( - req: Request, - res: Response -): Promise { - try { - const hruid = req.query.hruid as string; - const full = req.query.full === 'true'; - const language = (req.query.language as string) || FALLBACK_LANG; - - if (!hruid) { - res.status(400).json({ error: 'HRUID query is required.' }); - return; - } - - let learningObjects: FilteredLearningObject[] | string[]; - if (full) { - learningObjects = await getLearningObjectsFromPath(hruid, language); - } else { - learningObjects = await getLearningObjectIdsFromPath( - hruid, - language - ); - } - - res.json(learningObjects); - } catch (error) { - getLogger().error('Error fetching learning objects:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function getLearningObject( - req: Request, - res: Response -): Promise { - try { - const { hruid } = req.params; - const language = (req.query.language as string) || FALLBACK_LANG; - - if (!hruid) { - res.status(400).json({ error: 'HRUID parameter is required.' }); - return; - } - - const learningObject = await getLearningObjectById(hruid, language); - res.json(learningObject); - } catch (error) { - getLogger().error('Error fetching learning object:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} diff --git a/backend/src/controllers/learningPaths.ts b/backend/src/controllers/learningPaths.ts index 247877e7..0a7ff0ae 100644 --- a/backend/src/controllers/learningPaths.ts +++ b/backend/src/controllers/learningPaths.ts @@ -1,34 +1,25 @@ import { Request, Response } from 'express'; import { themes } from '../data/themes.js'; import { FALLBACK_LANG } from '../config.js'; -import { - fetchLearningPaths, - searchLearningPaths, -} from '../services/learningPaths.js'; import { getLogger } from '../logging/initalize.js'; +import learningPathService from '../services/learning-paths/learning-path-service.js'; +import { Language } from '../entities/content/language.js'; /** * Fetch learning paths based on query parameters. */ -export async function getLearningPaths( - req: Request, - res: Response -): Promise { +export async function getLearningPaths(req: Request, res: Response): Promise { try { 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 language = (req.query.language as Language) || FALLBACK_LANG; let hruidList; if (hruids) { - hruidList = Array.isArray(hruids) - ? hruids.map(String) - : [String(hruids)]; + hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; } else if (themeKey) { - const theme = themes.find((t) => { - return t.title === themeKey; - }); + const theme = themes.find((t) => t.title === themeKey); if (theme) { hruidList = theme.hruids; } else { @@ -38,29 +29,17 @@ export async function getLearningPaths( return; } } else if (searchQuery) { - const searchResults = await searchLearningPaths( - searchQuery, - language - ); + const searchResults = await learningPathService.searchLearningPaths(searchQuery, language); res.json(searchResults); return; } else { - hruidList = themes.flatMap((theme) => { - return theme.hruids; - }); + hruidList = themes.flatMap((theme) => theme.hruids); } - const learningPaths = await fetchLearningPaths( - hruidList, - language, - `HRUIDs: ${hruidList.join(', ')}` - ); + const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); res.json(learningPaths.data); } catch (error) { - getLogger().error( - '❌ Unexpected error fetching learning paths:', - error - ); + getLogger().error('❌ Unexpected error fetching learning paths:', error); res.status(500).json({ error: 'Internal server error' }); } } diff --git a/backend/src/controllers/themes.ts b/backend/src/controllers/themes.ts index a85cac21..fe1eb818 100644 --- a/backend/src/controllers/themes.ts +++ b/backend/src/controllers/themes.ts @@ -11,24 +11,19 @@ interface Translations { export function getThemes(req: Request, res: Response) { const language = (req.query.language as string)?.toLowerCase() || 'nl'; const translations = loadTranslations(language); - const themeList = themes.map((theme) => { - return { - key: theme.title, - title: - translations.curricula_page[theme.title]?.title || theme.title, - description: translations.curricula_page[theme.title]?.description, - image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, - }; - }); + const themeList = themes.map((theme) => ({ + key: theme.title, + title: translations.curricula_page[theme.title]?.title || theme.title, + description: translations.curricula_page[theme.title]?.description, + image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, + })); res.json(themeList); } export function getThemeByTitle(req: Request, res: Response) { const themeKey = req.params.theme; - const theme = themes.find((t) => { - return t.title === themeKey; - }); + const theme = themes.find((t) => t.title === themeKey); if (theme) { res.json(theme.hruids); diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts index c55bee00..c3c457d3 100644 --- a/backend/src/data/assignments/assignment-repository.ts +++ b/backend/src/data/assignments/assignment-repository.ts @@ -3,10 +3,7 @@ import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Class } from '../../entities/classes/class.entity.js'; export class AssignmentRepository extends DwengoEntityRepository { - public findByClassAndId( - within: Class, - id: number - ): Promise { + public findByClassAndId(within: Class, id: number): Promise { return this.findOne({ within: within, id: id }); } public findAllAssignmentsInClass(within: Class): Promise { diff --git a/backend/src/data/assignments/group-repository.ts b/backend/src/data/assignments/group-repository.ts index ff8ca507..df92eaae 100644 --- a/backend/src/data/assignments/group-repository.ts +++ b/backend/src/data/assignments/group-repository.ts @@ -3,24 +3,16 @@ import { Group } from '../../entities/assignments/group.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js'; export class GroupRepository extends DwengoEntityRepository { - public findByAssignmentAndGroupNumber( - assignment: Assignment, - groupNumber: number - ): Promise { + public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise { return this.findOne({ assignment: assignment, groupNumber: groupNumber, }); } - public findAllGroupsForAssignment( - assignment: Assignment - ): Promise { + public findAllGroupsForAssignment(assignment: Assignment): Promise { return this.findAll({ where: { assignment: assignment } }); } - public deleteByAssignmentAndGroupNumber( - assignment: Assignment, - groupNumber: number - ) { + public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) { return this.deleteWhere({ assignment: assignment, groupNumber: groupNumber, diff --git a/backend/src/data/assignments/submission-repository.ts b/backend/src/data/assignments/submission-repository.ts index 5332d050..faa9fef1 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -5,10 +5,7 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object import { Student } from '../../entities/users/student.entity.js'; export class SubmissionRepository extends DwengoEntityRepository { - public findSubmissionByLearningObjectAndSubmissionNumber( - loId: LearningObjectIdentifier, - submissionNumber: number - ): Promise { + public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { return this.findOne({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, @@ -17,10 +14,7 @@ export class SubmissionRepository extends DwengoEntityRepository { }); } - public findMostRecentSubmissionForStudent( - loId: LearningObjectIdentifier, - submitter: Student - ): Promise { + public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { return this.findOne( { learningObjectHruid: loId.hruid, @@ -32,10 +26,7 @@ export class SubmissionRepository extends DwengoEntityRepository { ); } - public findMostRecentSubmissionForGroup( - loId: LearningObjectIdentifier, - group: Group - ): Promise { + public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise { return this.findOne( { learningObjectHruid: loId.hruid, @@ -47,10 +38,7 @@ export class SubmissionRepository extends DwengoEntityRepository { ); } - public deleteSubmissionByLearningObjectAndSubmissionNumber( - loId: LearningObjectIdentifier, - submissionNumber: number - ): Promise { + public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { return this.deleteWhere({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, diff --git a/backend/src/data/classes/teacher-invitation-repository.ts b/backend/src/data/classes/teacher-invitation-repository.ts index ae2713c8..6b94deec 100644 --- a/backend/src/data/classes/teacher-invitation-repository.ts +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -4,24 +4,16 @@ import { TeacherInvitation } from '../../entities/classes/teacher-invitation.ent import { Teacher } from '../../entities/users/teacher.entity.js'; export class TeacherInvitationRepository extends DwengoEntityRepository { - public findAllInvitationsForClass( - clazz: Class - ): Promise { + public findAllInvitationsForClass(clazz: Class): Promise { return this.findAll({ where: { class: clazz } }); } public findAllInvitationsBy(sender: Teacher): Promise { return this.findAll({ where: { sender: sender } }); } - public findAllInvitationsFor( - receiver: Teacher - ): Promise { + public findAllInvitationsFor(receiver: Teacher): Promise { return this.findAll({ where: { receiver: receiver } }); } - public deleteBy( - clazz: Class, - sender: Teacher, - receiver: Teacher - ): Promise { + public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { return this.deleteWhere({ sender: sender, receiver: receiver, diff --git a/backend/src/data/content/attachment-repository.ts b/backend/src/data/content/attachment-repository.ts index 3268be90..95c5ab1c 100644 --- a/backend/src/data/content/attachment-repository.ts +++ b/backend/src/data/content/attachment-repository.ts @@ -1,16 +1,37 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Attachment } from '../../entities/content/attachment.entity.js'; -import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { Language } from '../../entities/content/language'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier'; export class AttachmentRepository extends DwengoEntityRepository { - public findByLearningObjectAndNumber( - learningObject: LearningObject, - sequenceNumber: number - ) { + public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise { return this.findOne({ - learningObject: learningObject, - sequenceNumber: sequenceNumber, + learningObject: { + hruid: learningObjectId.hruid, + language: learningObjectId.language, + version: learningObjectId.version, + }, + name: name, }); } + + public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise { + return this.findOne( + { + learningObject: { + hruid: hruid, + language: language, + }, + name: attachmentName, + }, + { + orderBy: { + learningObject: { + version: 'DESC', + }, + }, + } + ); + } // This repository is read-only for now since creating own learning object is an extension feature. } diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index 5d30b956..f9b6bfcb 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -1,16 +1,34 @@ 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 '../../entities/content/language'; export class LearningObjectRepository extends DwengoEntityRepository { - public findByIdentifier( - identifier: LearningObjectIdentifier - ): Promise { - return this.findOne({ - hruid: identifier.hruid, - language: identifier.language, - version: identifier.version, - }); + public findByIdentifier(identifier: LearningObjectIdentifier): Promise { + return this.findOne( + { + hruid: identifier.hruid, + language: identifier.language, + version: identifier.version, + }, + { + populate: ['keywords'], + } + ); + } + + public findLatestByHruidAndLanguage(hruid: string, language: Language) { + return this.findOne( + { + hruid: hruid, + language: language, + }, + { + populate: ['keywords'], + orderBy: { + version: 'DESC', + }, + } + ); } - // This repository is read-only for now since creating own learning object is an extension feature. } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 3ffb1e7f..a2f9b47e 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -3,11 +3,24 @@ import { LearningPath } from '../../entities/content/learning-path.entity.js'; import { Language } from '../../entities/content/language.js'; export class LearningPathRepository extends DwengoEntityRepository { - public findByHruidAndLanguage( - hruid: string, - language: Language - ): Promise { - return this.findOne({ hruid: hruid, language: language }); + public findByHruidAndLanguage(hruid: string, language: Language): Promise { + return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); + } + + /** + * Returns all learning paths which have the given language and whose title OR description contains the + * query string. + * + * @param query The query string we want to seach for in the title or description. + * @param language The language of the learning paths we want to find. + */ + public async findByQueryStringAndLanguage(query: string, language: Language): Promise { + return this.findAll({ + where: { + language: language, + $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], + }, + populate: ['nodes', 'nodes.transitions'], + }); } - // This repository is read-only for now since creating own learning object is an extension feature. } diff --git a/backend/src/data/dwengo-entity-repository.ts b/backend/src/data/dwengo-entity-repository.ts index e29d9ede..6538d6f5 100644 --- a/backend/src/data/dwengo-entity-repository.ts +++ b/backend/src/data/dwengo-entity-repository.ts @@ -1,8 +1,6 @@ import { EntityRepository, FilterQuery } from '@mikro-orm/core'; -export abstract class DwengoEntityRepository< - T extends object, -> extends EntityRepository { +export abstract class DwengoEntityRepository extends EntityRepository { public async save(entity: T) { const em = this.getEntityManager(); em.persist(entity); diff --git a/backend/src/data/questions/answer-repository.ts b/backend/src/data/questions/answer-repository.ts index 6c45211c..a28342bd 100644 --- a/backend/src/data/questions/answer-repository.ts +++ b/backend/src/data/questions/answer-repository.ts @@ -4,15 +4,13 @@ import { Question } from '../../entities/questions/question.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; export class AnswerRepository extends DwengoEntityRepository { - public createAnswer(answer: { - toQuestion: Question; - author: Teacher; - content: string; - }): Promise { - const answerEntity = new Answer(); - answerEntity.toQuestion = answer.toQuestion; - answerEntity.author = answer.author; - answerEntity.content = answer.content; + public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise { + const answerEntity = this.create({ + toQuestion: answer.toQuestion, + author: answer.author, + content: answer.content, + timestamp: new Date(), + }); return this.insert(answerEntity); } public findAllAnswersToQuestion(question: Question): Promise { @@ -21,10 +19,7 @@ export class AnswerRepository extends DwengoEntityRepository { orderBy: { sequenceNumber: 'ASC' }, }); } - public removeAnswerByQuestionAndSequenceNumber( - question: Question, - sequenceNumber: number - ): Promise { + public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise { return this.deleteWhere({ toQuestion: question, sequenceNumber: sequenceNumber, diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 8852a9ba..4099c528 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -4,12 +4,15 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object import { Student } from '../../entities/users/student.entity.js'; export class QuestionRepository extends DwengoEntityRepository { - public createQuestion(question: { - loId: LearningObjectIdentifier; - author: Student; - content: string; - }): Promise { - const questionEntity = new Question(); + public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { + const questionEntity = this.create({ + learningObjectHruid: question.loId.hruid, + learningObjectLanguage: question.loId.language, + learningObjectVersion: question.loId.version, + author: question.author, + content: question.content, + timestamp: new Date(), + }); questionEntity.learningObjectHruid = question.loId.hruid; questionEntity.learningObjectLanguage = question.loId.language; questionEntity.learningObjectVersion = question.loId.version; @@ -17,9 +20,7 @@ export class QuestionRepository extends DwengoEntityRepository { questionEntity.content = question.content; return this.insert(questionEntity); } - public findAllQuestionsAboutLearningObject( - loId: LearningObjectIdentifier - ): Promise { + public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { return this.findAll({ where: { learningObjectHruid: loId.hruid, @@ -31,10 +32,7 @@ export class QuestionRepository extends DwengoEntityRepository { }, }); } - public removeQuestionByLearningObjectAndSequenceNumber( - loId: LearningObjectIdentifier, - sequenceNumber: number - ): Promise { + public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise { return this.deleteWhere({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, diff --git a/backend/src/data/repositories.ts b/backend/src/data/repositories.ts index 843eb1ac..3daa026d 100644 --- a/backend/src/data/repositories.ts +++ b/backend/src/data/repositories.ts @@ -1,9 +1,4 @@ -import { - AnyEntity, - EntityManager, - EntityName, - EntityRepository, -} from '@mikro-orm/core'; +import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-orm/core'; import { forkEntityManager } from '../orm.js'; import { StudentRepository } from './users/student-repository.js'; import { Student } from '../entities/users/student.entity.js'; @@ -33,6 +28,8 @@ import { LearningPath } from '../entities/content/learning-path.entity.js'; import { LearningPathRepository } from './content/learning-path-repository.js'; import { AttachmentRepository } from './content/attachment-repository.js'; import { Attachment } from '../entities/content/attachment.entity.js'; +import { LearningPathNode } from '../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js'; let entityManager: EntityManager | undefined; @@ -43,9 +40,7 @@ export function transactional(f: () => Promise) { entityManager?.transactional(f); } -function repositoryGetter>( - entity: EntityName -): () => R { +function repositoryGetter>(entity: EntityName): () => R { let cachedRepo: R | undefined; return (): R => { if (!cachedRepo) { @@ -60,60 +55,26 @@ function repositoryGetter>( /* Users */ export const getUserRepository = repositoryGetter(User); -export const getStudentRepository = repositoryGetter< - Student, - StudentRepository ->(Student); -export const getTeacherRepository = repositoryGetter< - Teacher, - TeacherRepository ->(Teacher); +export const getStudentRepository = repositoryGetter(Student); +export const getTeacherRepository = repositoryGetter(Teacher); /* Classes */ -export const getClassRepository = repositoryGetter( - Class -); -export const getClassJoinRequestRepository = repositoryGetter< - ClassJoinRequest, - ClassJoinRequestRepository ->(ClassJoinRequest); -export const getTeacherInvitationRepository = repositoryGetter< - TeacherInvitation, - TeacherInvitationRepository ->(TeacherInvitationRepository); +export const getClassRepository = repositoryGetter(Class); +export const getClassJoinRequestRepository = repositoryGetter(ClassJoinRequest); +export const getTeacherInvitationRepository = repositoryGetter(TeacherInvitation); /* Assignments */ -export const getAssignmentRepository = repositoryGetter< - Assignment, - AssignmentRepository ->(Assignment); -export const getGroupRepository = repositoryGetter( - Group -); -export const getSubmissionRepository = repositoryGetter< - Submission, - SubmissionRepository ->(Submission); +export const getAssignmentRepository = repositoryGetter(Assignment); +export const getGroupRepository = repositoryGetter(Group); +export const getSubmissionRepository = repositoryGetter(Submission); /* Questions and answers */ -export const getQuestionRepository = repositoryGetter< - Question, - QuestionRepository ->(Question); -export const getAnswerRepository = repositoryGetter( - Answer -); +export const getQuestionRepository = repositoryGetter(Question); +export const getAnswerRepository = repositoryGetter(Answer); /* Learning content */ -export const getLearningObjectRepository = repositoryGetter< - LearningObject, - LearningObjectRepository ->(LearningObject); -export const getLearningPathRepository = repositoryGetter< - LearningPath, - LearningPathRepository ->(LearningPath); -export const getAttachmentRepository = repositoryGetter< - Attachment, - AttachmentRepository ->(Assignment); +export const getLearningObjectRepository = repositoryGetter(LearningObject); +export const getLearningPathRepository = repositoryGetter(LearningPath); +export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode); +export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition); +export const getAttachmentRepository = repositoryGetter(Attachment); diff --git a/backend/src/data/themes.ts b/backend/src/data/themes.ts index dd79400c..b0fc930c 100644 --- a/backend/src/data/themes.ts +++ b/backend/src/data/themes.ts @@ -23,13 +23,7 @@ export const themes: Theme[] = [ }, { title: 'art', - hruids: [ - 'pn_werking', - 'un_artificiele_intelligentie', - 'art1', - 'art2', - 'art3', - ], + hruids: ['pn_werking', 'un_artificiele_intelligentie', 'art1', 'art2', 'art3'], }, { title: 'socialrobot', @@ -37,12 +31,7 @@ export const themes: Theme[] = [ }, { title: 'agriculture', - hruids: [ - 'pn_werking', - 'un_artificiele_intelligentie', - 'agri_landbouw', - 'agri_lopendeband', - ], + hruids: ['pn_werking', 'un_artificiele_intelligentie', 'agri_landbouw', 'agri_lopendeband'], }, { title: 'wegostem', @@ -83,16 +72,7 @@ export const themes: Theme[] = [ }, { title: 'python_programming', - hruids: [ - 'pn_werking', - 'pn_datatypes', - 'pn_operatoren', - 'pn_structuren', - 'pn_functies', - 'art2', - 'stem_insectbooks', - 'un_algoenprog', - ], + hruids: ['pn_werking', 'pn_datatypes', 'pn_operatoren', 'pn_structuren', 'pn_functies', 'art2', 'stem_insectbooks', 'un_algoenprog'], }, { title: 'stem', @@ -110,15 +90,7 @@ export const themes: Theme[] = [ }, { title: 'care', - hruids: [ - 'pn_werking', - 'un_artificiele_intelligentie', - 'aiz1_zorg', - 'aiz2_grafen', - 'aiz3_unplugged', - 'aiz4_eindtermen', - 'aiz5_triage', - ], + hruids: ['pn_werking', 'un_artificiele_intelligentie', 'aiz1_zorg', 'aiz2_grafen', 'aiz3_unplugged', 'aiz4_eindtermen', 'aiz5_triage'], }, { title: 'chatbot', diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index 89952c64..cda27d66 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -1,23 +1,12 @@ -import { - Entity, - Enum, - ManyToOne, - OneToMany, - PrimaryKey, - Property, -} from '@mikro-orm/core'; +import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Class } from '../classes/class.entity.js'; import { Group } from './group.entity.js'; import { Language } from '../content/language.js'; +import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; -@Entity() +@Entity({ repository: () => AssignmentRepository }) export class Assignment { - @ManyToOne({ - entity: () => { - return Class; - }, - primary: true, - }) + @ManyToOne({ entity: () => Class, primary: true }) within!: Class; @PrimaryKey({ type: 'number' }) @@ -32,18 +21,9 @@ export class Assignment { @Property({ type: 'string' }) learningPathHruid!: string; - @Enum({ - items: () => { - return Language; - }, - }) + @Enum({ items: () => Language }) learningPathLanguage!: Language; - @OneToMany({ - entity: () => { - return Group; - }, - mappedBy: 'assignment', - }) + @OneToMany({ entity: () => Group, mappedBy: 'assignment' }) groups!: Group[]; } diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts index 5b224087..632ad722 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -1,13 +1,12 @@ import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; import { Assignment } from './assignment.entity.js'; import { Student } from '../users/student.entity.js'; +import { GroupRepository } from '../../data/assignments/group-repository.js'; -@Entity() +@Entity({ repository: () => GroupRepository }) export class Group { @ManyToOne({ - entity: () => { - return Assignment; - }, + entity: () => Assignment, primary: true, }) assignment!: Assignment; @@ -16,9 +15,7 @@ export class Group { groupNumber!: number; @ManyToMany({ - entity: () => { - return Student; - }, + entity: () => Student, }) members!: Student[]; } diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 1bc28add..f6f8b3c7 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -2,30 +2,27 @@ import { Student } from '../users/student.entity.js'; import { Group } from './group.entity.js'; import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from '../content/language.js'; +import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; -@Entity() +@Entity({ repository: () => SubmissionRepository }) export class Submission { @PrimaryKey({ type: 'string' }) learningObjectHruid!: string; @Enum({ - items: () => { - return Language; - }, + items: () => Language, primary: true, }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'string' }) - learningObjectVersion: string = '1'; + @PrimaryKey({ type: 'numeric' }) + learningObjectVersion: number = 1; @PrimaryKey({ type: 'integer' }) submissionNumber!: number; @ManyToOne({ - entity: () => { - return Student; - }, + entity: () => Student, }) submitter!: Student; @@ -33,9 +30,7 @@ export class Submission { submissionTime!: Date; @ManyToOne({ - entity: () => { - return Group; - }, + entity: () => Group, nullable: true, }) onBehalfOf?: Group; diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index 0ae38cd1..62ed37d0 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -1,28 +1,23 @@ import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; +import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; -@Entity() +@Entity({ repository: () => ClassJoinRequestRepository }) export class ClassJoinRequest { @ManyToOne({ - entity: () => { - return Student; - }, + entity: () => Student, primary: true, }) requester!: Student; @ManyToOne({ - entity: () => { - return Class; - }, + entity: () => Class, primary: true, }) class!: Class; - @Enum(() => { - return ClassJoinRequestStatus; - }) + @Enum(() => ClassJoinRequestStatus) status!: ClassJoinRequestStatus; } diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index ecc11748..d4e44bf9 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -1,15 +1,10 @@ -import { - Collection, - Entity, - ManyToMany, - PrimaryKey, - Property, -} from '@mikro-orm/core'; +import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { v4 } from 'uuid'; import { Teacher } from '../users/teacher.entity.js'; import { Student } from '../users/student.entity.js'; +import { ClassRepository } from '../../data/classes/class-repository.js'; -@Entity() +@Entity({ repository: () => ClassRepository }) export class Class { @PrimaryKey() classId = v4(); @@ -17,13 +12,9 @@ export class Class { @Property({ type: 'string' }) displayName!: string; - @ManyToMany(() => { - return Teacher; - }) + @ManyToMany(() => Teacher) teachers!: Collection; - @ManyToMany(() => { - return Student; - }) + @ManyToMany(() => Student) students!: Collection; } diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index 98d2bdd4..eb57d98a 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -1,32 +1,30 @@ import { Entity, ManyToOne } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { Class } from './class.entity.js'; +import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; /** * Invitation of a teacher into a class (in order to teach it). */ -@Entity() +@Entity({ repository: () => TeacherInvitationRepository }) +@Entity({ + repository: () => TeacherInvitationRepository, +}) export class TeacherInvitation { @ManyToOne({ - entity: () => { - return Teacher; - }, + entity: () => Teacher, primary: true, }) sender!: Teacher; @ManyToOne({ - entity: () => { - return Teacher; - }, + entity: () => Teacher, primary: true, }) receiver!: Teacher; @ManyToOne({ - entity: () => { - return Class; - }, + entity: () => Class, primary: true, }) class!: Class; diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts index 7a9dd946..0c0f53c4 100644 --- a/backend/src/entities/content/attachment.entity.ts +++ b/backend/src/entities/content/attachment.entity.ts @@ -1,18 +1,17 @@ import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { LearningObject } from './learning-object.entity.js'; +import { AttachmentRepository } from '../../data/content/attachment-repository.js'; -@Entity() +@Entity({ repository: () => AttachmentRepository }) export class Attachment { @ManyToOne({ - entity: () => { - return LearningObject; - }, + entity: () => LearningObject, primary: true, }) learningObject!: LearningObject; - @PrimaryKey({ type: 'integer' }) - sequenceNumber!: number; + @PrimaryKey({ type: 'string' }) + name!: string; @Property({ type: 'string' }) mimeType!: string; diff --git a/backend/src/entities/content/language.ts b/backend/src/entities/content/language.ts index b5d18c80..d7687331 100644 --- a/backend/src/entities/content/language.ts +++ b/backend/src/entities/content/language.ts @@ -1,6 +1,186 @@ export enum Language { + Afar = 'aa', + Abkhazian = 'ab', + Afrikaans = 'af', + Akan = 'ak', + Albanian = 'sq', + Amharic = 'am', + Arabic = 'ar', + Aragonese = 'an', + Armenian = 'hy', + Assamese = 'as', + Avaric = 'av', + Avestan = 'ae', + Aymara = 'ay', + Azerbaijani = 'az', + Bashkir = 'ba', + Bambara = 'bm', + Basque = 'eu', + Belarusian = 'be', + Bengali = 'bn', + Bihari = 'bh', + Bislama = 'bi', + Bosnian = 'bs', + Breton = 'br', + Bulgarian = 'bg', + Burmese = 'my', + Catalan = 'ca', + Chamorro = 'ch', + Chechen = 'ce', + Chinese = 'zh', + ChurchSlavic = 'cu', + Chuvash = 'cv', + Cornish = 'kw', + Corsican = 'co', + Cree = 'cr', + Czech = 'cs', + Danish = 'da', + Divehi = 'dv', Dutch = 'nl', - French = 'fr', + Dzongkha = 'dz', English = 'en', - Germany = 'de', + Esperanto = 'eo', + Estonian = 'et', + Ewe = 'ee', + Faroese = 'fo', + Fijian = 'fj', + Finnish = 'fi', + French = 'fr', + Frisian = 'fy', + Fulah = 'ff', + Georgian = 'ka', + German = 'de', + Gaelic = 'gd', + Irish = 'ga', + Galician = 'gl', + Manx = 'gv', + Greek = 'el', + Guarani = 'gn', + Gujarati = 'gu', + Haitian = 'ht', + Hausa = 'ha', + Hebrew = 'he', + Herero = 'hz', + Hindi = 'hi', + HiriMotu = 'ho', + Croatian = 'hr', + Hungarian = 'hu', + Igbo = 'ig', + Icelandic = 'is', + Ido = 'io', + SichuanYi = 'ii', + Inuktitut = 'iu', + Interlingue = 'ie', + Interlingua = 'ia', + Indonesian = 'id', + Inupiaq = 'ik', + Italian = 'it', + Javanese = 'jv', + Japanese = 'ja', + Kalaallisut = 'kl', + Kannada = 'kn', + Kashmiri = 'ks', + Kanuri = 'kr', + Kazakh = 'kk', + Khmer = 'km', + Kikuyu = 'ki', + Kinyarwanda = 'rw', + Kirghiz = 'ky', + Komi = 'kv', + Kongo = 'kg', + Korean = 'ko', + Kuanyama = 'kj', + Kurdish = 'ku', + Lao = 'lo', + Latin = 'la', + Latvian = 'lv', + Limburgan = 'li', + Lingala = 'ln', + Lithuanian = 'lt', + Luxembourgish = 'lb', + LubaKatanga = 'lu', + Ganda = 'lg', + Macedonian = 'mk', + Marshallese = 'mh', + Malayalam = 'ml', + Maori = 'mi', + Marathi = 'mr', + Malay = 'ms', + Malagasy = 'mg', + Maltese = 'mt', + Mongolian = 'mn', + Nauru = 'na', + Navajo = 'nv', + SouthNdebele = 'nr', + NorthNdebele = 'nd', + Ndonga = 'ng', + Nepali = 'ne', + NorwegianNynorsk = 'nn', + NorwegianBokmal = 'nb', + Norwegian = 'no', + Chichewa = 'ny', + Occitan = 'oc', + Ojibwa = 'oj', + Oriya = 'or', + Oromo = 'om', + Ossetian = 'os', + Punjabi = 'pa', + Persian = 'fa', + Pali = 'pi', + Polish = 'pl', + Portuguese = 'pt', + Pashto = 'ps', + Quechua = 'qu', + Romansh = 'rm', + Romanian = 'ro', + Rundi = 'rn', + Russian = 'ru', + Sango = 'sg', + Sanskrit = 'sa', + Sinhala = 'si', + Slovak = 'sk', + Slovenian = 'sl', + NorthernSami = 'se', + Samoan = 'sm', + Shona = 'sn', + Sindhi = 'sd', + Somali = 'so', + Sotho = 'st', + Spanish = 'es', + Sardinian = 'sc', + Serbian = 'sr', + Swati = 'ss', + Sundanese = 'su', + Swahili = 'sw', + Swedish = 'sv', + Tahitian = 'ty', + Tamil = 'ta', + Tatar = 'tt', + Telugu = 'te', + Tajik = 'tg', + Tagalog = 'tl', + Thai = 'th', + Tibetan = 'bo', + Tigrinya = 'ti', + Tonga = 'to', + Tswana = 'tn', + Tsonga = 'ts', + Turkmen = 'tk', + Turkish = 'tr', + Twi = 'tw', + Uighur = 'ug', + Ukrainian = 'uk', + Urdu = 'ur', + Uzbek = 'uz', + Venda = 've', + Vietnamese = 'vi', + Volapuk = 'vo', + Welsh = 'cy', + Walloon = 'wa', + Wolof = 'wo', + Xhosa = 'xh', + Yiddish = 'yi', + Yoruba = 'yo', + Zhuang = 'za', + Zulu = 'zu', } diff --git a/backend/src/entities/content/learning-object-identifier.ts b/backend/src/entities/content/learning-object-identifier.ts index 48d173c1..3c020bd7 100644 --- a/backend/src/entities/content/learning-object-identifier.ts +++ b/backend/src/entities/content/learning-object-identifier.ts @@ -4,6 +4,6 @@ export class LearningObjectIdentifier { constructor( public hruid: string, public language: Language, - public version: string + public version: number ) {} } diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index bf499e8a..9eda22ba 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -1,105 +1,10 @@ -import { - Embeddable, - Embedded, - Entity, - Enum, - ManyToMany, - OneToMany, - PrimaryKey, - Property, -} from '@mikro-orm/core'; +import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from './language.js'; import { Attachment } from './attachment.entity.js'; import { Teacher } from '../users/teacher.entity.js'; - -@Entity() -export class LearningObject { - @PrimaryKey({ type: 'string' }) - hruid!: string; - - @Enum({ - items: () => { - return Language; - }, - primary: true, - }) - language!: Language; - - @PrimaryKey({ type: 'string' }) - version: string = '1'; - - @ManyToMany({ - entity: () => { - return Teacher; - }, - }) - admins!: Teacher[]; - - @Property({ type: 'string' }) - title!: string; - - @Property({ type: 'text' }) - description!: string; - - @Property({ type: 'string' }) - contentType!: string; - - @Property({ type: 'array' }) - keywords: string[] = []; - - @Property({ type: 'array', nullable: true }) - targetAges?: number[]; - - @Property({ type: 'bool' }) - teacherExclusive: boolean = false; - - @Property({ type: 'array' }) - skosConcepts!: string[]; - - @Embedded({ - entity: () => { - return EducationalGoal; - }, - array: true, - }) - educationalGoals: EducationalGoal[] = []; - - @Property({ type: 'string' }) - copyright: string = ''; - - @Property({ type: 'string' }) - license: string = ''; - - @Property({ type: 'smallint', nullable: true }) - difficulty?: number; - - @Property({ type: 'integer' }) - estimatedTime!: number; - - @Embedded({ - entity: () => { - return ReturnValue; - }, - }) - returnValue!: ReturnValue; - - @Property({ type: 'bool' }) - available: boolean = true; - - @Property({ type: 'string', nullable: true }) - contentLocation?: string; - - @OneToMany({ - entity: () => { - return Attachment; - }, - mappedBy: 'learningObject', - }) - attachments: Attachment[] = []; - - @Property({ type: 'blob' }) - content!: Buffer; -} +import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; +import { v4 } from 'uuid'; +import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; @Embeddable() export class EducationalGoal { @@ -119,11 +24,84 @@ export class ReturnValue { callbackSchema!: string; } -export enum ContentType { - Markdown = 'text/markdown', - Image = 'image/image', - Mpeg = 'audio/mpeg', - Pdf = 'application/pdf', - Extern = 'extern', - Blockly = 'Blockly', +@Entity({ repository: () => LearningObjectRepository }) +export class LearningObject { + @PrimaryKey({ type: 'string' }) + hruid!: string; + + @Enum({ + items: () => Language, + primary: true, + }) + language!: Language; + + @PrimaryKey({ type: 'number' }) + version: number = 1; + + @Property({ type: 'uuid', unique: true }) + uuid = v4(); + + @ManyToMany({ + entity: () => Teacher, + }) + admins!: Teacher[]; + + @Property({ type: 'string' }) + title!: string; + + @Property({ type: 'text' }) + description!: string; + + @Property({ type: 'string' }) + contentType!: DwengoContentType; + + @Property({ type: 'array' }) + keywords: string[] = []; + + @Property({ type: 'array', nullable: true }) + targetAges?: number[] = []; + + @Property({ type: 'bool' }) + teacherExclusive: boolean = false; + + @Property({ type: 'array' }) + skosConcepts: string[] = []; + + @Embedded({ + entity: () => EducationalGoal, + array: true, + }) + educationalGoals: EducationalGoal[] = []; + + @Property({ type: 'string' }) + copyright: string = ''; + + @Property({ type: 'string' }) + license: string = ''; + + @Property({ type: 'smallint', nullable: true }) + difficulty?: number; + + @Property({ type: 'integer', nullable: true }) + estimatedTime?: number; + + @Embedded({ + entity: () => ReturnValue, + }) + returnValue!: ReturnValue; + + @Property({ type: 'bool' }) + available: boolean = true; + + @Property({ type: 'string', nullable: true }) + contentLocation?: string; + + @OneToMany({ + entity: () => Attachment, + mappedBy: 'learningObject', + }) + attachments: Attachment[] = []; + + @Property({ type: 'blob' }) + content!: Buffer; } diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts new file mode 100644 index 00000000..03499270 --- /dev/null +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -0,0 +1,37 @@ +import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { Language } from './language.js'; +import { LearningPath } from './learning-path.entity.js'; +import { LearningPathTransition } from './learning-path-transition.entity.js'; + +@Entity() +export class LearningPathNode { + @ManyToOne({ entity: () => LearningPath, primary: true }) + learningPath!: Rel; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + nodeNumber!: number; + + @Property({ type: 'string' }) + learningObjectHruid!: string; + + @Enum({ items: () => Language }) + language!: Language; + + @Property({ type: 'number' }) + version!: number; + + @Property({ type: 'text', nullable: true }) + instruction?: string; + + @Property({ type: 'bool' }) + startNode!: boolean; + + @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) + transitions: LearningPathTransition[] = []; + + @Property({ length: 3 }) + createdAt: Date = new Date(); + + @Property({ length: 3, onUpdate: () => new Date() }) + updatedAt: Date = new Date(); +} diff --git a/backend/src/entities/content/learning-path-transition.entity.ts b/backend/src/entities/content/learning-path-transition.entity.ts new file mode 100644 index 00000000..7d6601a3 --- /dev/null +++ b/backend/src/entities/content/learning-path-transition.entity.ts @@ -0,0 +1,17 @@ +import { Entity, ManyToOne, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { LearningPathNode } from './learning-path-node.entity.js'; + +@Entity() +export class LearningPathTransition { + @ManyToOne({ entity: () => LearningPathNode, primary: true }) + node!: Rel; + + @PrimaryKey({ type: 'numeric' }) + transitionNumber!: number; + + @Property({ type: 'string' }) + condition!: string; + + @ManyToOne({ entity: () => LearningPathNode }) + next!: Rel; +} diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 28d3cadd..888cc0cf 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -1,34 +1,18 @@ -import { - Embeddable, - Embedded, - Entity, - Enum, - ManyToMany, - OneToOne, - PrimaryKey, - Property, -} from '@mikro-orm/core'; +import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from './language.js'; import { Teacher } from '../users/teacher.entity.js'; +import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; +import { LearningPathNode } from './learning-path-node.entity.js'; -@Entity() +@Entity({ repository: () => LearningPathRepository }) export class LearningPath { @PrimaryKey({ type: 'string' }) hruid!: string; - @Enum({ - items: () => { - return Language; - }, - primary: true, - }) + @Enum({ items: () => Language, primary: true }) language!: Language; - @ManyToMany({ - entity: () => { - return Teacher; - }, - }) + @ManyToMany({ entity: () => Teacher }) admins!: Teacher[]; @Property({ type: 'string' }) @@ -37,57 +21,9 @@ export class LearningPath { @Property({ type: 'text' }) description!: string; - @Property({ type: 'blob' }) - image!: string; + @Property({ type: 'blob', nullable: true }) + image: Buffer | null = null; - @Embedded({ - entity: () => { - return LearningPathNode; - }, - array: true, - }) + @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) nodes: LearningPathNode[] = []; } - -@Embeddable() -export class LearningPathNode { - @Property({ type: 'string' }) - learningObjectHruid!: string; - - @Enum({ - items: () => { - return Language; - }, - }) - language!: Language; - - @Property({ type: 'string' }) - version!: string; - - @Property({ type: 'longtext' }) - instruction!: string; - - @Property({ type: 'bool' }) - startNode!: boolean; - - @Embedded({ - entity: () => { - return LearningPathTransition; - }, - array: true, - }) - transitions!: LearningPathTransition[]; -} - -@Embeddable() -export class LearningPathTransition { - @Property({ type: 'string' }) - condition!: string; - - @OneToOne({ - entity: () => { - return LearningPathNode; - }, - }) - next!: LearningPathNode; -} diff --git a/backend/src/entities/questions/answer.entity.ts b/backend/src/entities/questions/answer.entity.ts index f0b67a54..96bdc27d 100644 --- a/backend/src/entities/questions/answer.entity.ts +++ b/backend/src/entities/questions/answer.entity.ts @@ -1,27 +1,24 @@ import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Question } from './question.entity.js'; import { Teacher } from '../users/teacher.entity.js'; +import { AnswerRepository } from '../../data/questions/answer-repository.js'; -@Entity() +@Entity({ repository: () => AnswerRepository }) export class Answer { @ManyToOne({ - entity: () => { - return Teacher; - }, + entity: () => Teacher, primary: true, }) author!: Teacher; @ManyToOne({ - entity: () => { - return Question; - }, + entity: () => Question, primary: true, }) toQuestion!: Question; - @PrimaryKey({ type: 'integer' }) - sequenceNumber!: number; + @PrimaryKey({ type: 'integer', autoincrement: true }) + sequenceNumber?: number; @Property({ type: 'datetime' }) timestamp: Date = new Date(); diff --git a/backend/src/entities/questions/question.entity.ts b/backend/src/entities/questions/question.entity.ts index 444d2179..058ba6b3 100644 --- a/backend/src/entities/questions/question.entity.ts +++ b/backend/src/entities/questions/question.entity.ts @@ -1,30 +1,27 @@ import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from '../content/language.js'; import { Student } from '../users/student.entity.js'; +import { QuestionRepository } from '../../data/questions/question-repository.js'; -@Entity() +@Entity({ repository: () => QuestionRepository }) export class Question { @PrimaryKey({ type: 'string' }) learningObjectHruid!: string; @Enum({ - items: () => { - return Language; - }, + items: () => Language, primary: true, }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'string' }) - learningObjectVersion: string = '1'; + @PrimaryKey({ type: 'number' }) + learningObjectVersion: number = 1; - @PrimaryKey({ type: 'integer' }) - sequenceNumber!: number; + @PrimaryKey({ type: 'integer', autoincrement: true }) + sequenceNumber?: number; @ManyToOne({ - entity: () => { - return Student; - }, + entity: () => Student, }) author!: Student; diff --git a/backend/src/entities/users/student.entity.ts b/backend/src/entities/users/student.entity.ts index c5632e84..da5b4367 100644 --- a/backend/src/entities/users/student.entity.ts +++ b/backend/src/entities/users/student.entity.ts @@ -5,19 +5,13 @@ import { Group } from '../assignments/group.entity.js'; import { StudentRepository } from '../../data/users/student-repository.js'; @Entity({ - repository: () => { - return StudentRepository; - }, + repository: () => StudentRepository, }) export class Student extends User { - @ManyToMany(() => { - return Class; - }) + @ManyToMany(() => Class) classes!: Collection; - @ManyToMany(() => { - return Group; - }) + @ManyToMany(() => Group) groups!: Collection; constructor( diff --git a/backend/src/entities/users/teacher.entity.ts b/backend/src/entities/users/teacher.entity.ts index 9f11a3b0..8e22d1de 100644 --- a/backend/src/entities/users/teacher.entity.ts +++ b/backend/src/entities/users/teacher.entity.ts @@ -1,11 +1,18 @@ import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; import { User } from './user.entity.js'; import { Class } from '../classes/class.entity.js'; +import { TeacherRepository } from '../../data/users/teacher-repository.js'; -@Entity() +@Entity({ repository: () => TeacherRepository }) export class Teacher extends User { - @ManyToMany(() => { - return Class; - }) + @ManyToMany(() => Class) classes!: Collection; + + constructor( + public username: string, + public firstName: string, + public lastName: string + ) { + super(); + } } diff --git a/backend/src/exceptions.ts b/backend/src/exceptions.ts index a76e2b72..e93a6c93 100644 --- a/backend/src/exceptions.ts +++ b/backend/src/exceptions.ts @@ -1,3 +1,17 @@ +/** + * Exception for HTTP 400 Bad Request + */ +export class BadRequestException extends Error { + public status = 400; + + constructor(error: string) { + super(error); + } +} + +/** + * Exception for HTTP 401 Unauthorized + */ export class UnauthorizedException extends Error { status = 401; constructor(message: string = 'Unauthorized') { @@ -5,9 +19,24 @@ export class UnauthorizedException extends Error { } } +/** + * Exception for HTTP 403 Forbidden + */ export class ForbiddenException extends Error { status = 403; + constructor(message: string = 'Forbidden') { super(message); } } + +/** + * Exception for HTTP 404 Not Found + */ +export class NotFoundException extends Error { + public status = 404; + + constructor(error: string) { + super(error); + } +} diff --git a/backend/src/interfaces/learningPath.ts b/backend/src/interfaces/learning-content.ts similarity index 81% rename from backend/src/interfaces/learningPath.ts rename to backend/src/interfaces/learning-content.ts index 1e2cc6ef..51474917 100644 --- a/backend/src/interfaces/learningPath.ts +++ b/backend/src/interfaces/learning-content.ts @@ -1,3 +1,5 @@ +import { Language } from '../entities/content/language'; + export interface Transition { default: boolean; _id: string; @@ -9,15 +11,22 @@ export interface Transition { }; } +export interface LearningObjectIdentifier { + hruid: string; + language: Language; + version?: number; +} + export interface LearningObjectNode { _id: string; learningobject_hruid: string; version: number; - language: string; + language: Language; start_node?: boolean; transitions: Transition[]; created_at: string; updatedAt: string; + done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. } export interface LearningPath { @@ -37,6 +46,11 @@ export interface LearningPath { __order: number; } +export interface LearningPathIdentifier { + hruid: string; + language: Language; +} + export interface EducationalGoal { source: string; id: string; @@ -52,7 +66,7 @@ export interface LearningObjectMetadata { uuid: string; hruid: string; version: number; - language: string; + language: Language; title: string; description: string; difficulty: number; @@ -75,9 +89,9 @@ export interface FilteredLearningObject { version: number; title: string; htmlUrl: string; - language: string; + language: Language; difficulty: number; - estimatedTime: number; + estimatedTime?: number; available: boolean; teacherExclusive: boolean; educationalGoals: EducationalGoal[]; diff --git a/backend/src/logging/initalize.ts b/backend/src/logging/initalize.ts index 18166408..1ff761c9 100644 --- a/backend/src/logging/initalize.ts +++ b/backend/src/logging/initalize.ts @@ -1,9 +1,4 @@ -import { - createLogger, - format, - Logger as WinstonLogger, - transports, -} from 'winston'; +import { createLogger, format, Logger as WinstonLogger, transports } from 'winston'; import LokiTransport from 'winston-loki'; import { LokiLabels } from 'loki-logger-ts'; import { LOG_LEVEL, LOKI_HOST } from '../config.js'; @@ -48,9 +43,7 @@ function initializeLogger(): Logger { transports: [lokiTransport, consoleTransport], }); - logger.debug( - `Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}` - ); + logger.debug(`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`); return logger; } diff --git a/backend/src/logging/mikroOrmLogger.ts b/backend/src/logging/mikroOrmLogger.ts index e8bc1fad..25bbac13 100644 --- a/backend/src/logging/mikroOrmLogger.ts +++ b/backend/src/logging/mikroOrmLogger.ts @@ -12,42 +12,28 @@ export class MikroOrmLogger extends DefaultLogger { switch (namespace) { case 'query': - this.logger.debug( - this.createMessage(namespace, message, context) - ); + this.logger.debug(this.createMessage(namespace, message, context)); break; case 'query-params': // TODO Which log level should this be? - this.logger.info( - this.createMessage(namespace, message, context) - ); + this.logger.info(this.createMessage(namespace, message, context)); break; case 'schema': - this.logger.info( - this.createMessage(namespace, message, context) - ); + this.logger.info(this.createMessage(namespace, message, context)); break; case 'discovery': - this.logger.debug( - this.createMessage(namespace, message, context) - ); + this.logger.debug(this.createMessage(namespace, message, context)); break; case 'info': - this.logger.info( - this.createMessage(namespace, message, context) - ); + this.logger.info(this.createMessage(namespace, message, context)); break; case 'deprecated': - this.logger.warn( - this.createMessage(namespace, message, context) - ); + this.logger.warn(this.createMessage(namespace, message, context)); break; default: switch (context?.level) { case 'info': - this.logger.info( - this.createMessage(namespace, message, context) - ); + this.logger.info(this.createMessage(namespace, message, context)); break; case 'warning': this.logger.warn(message); @@ -62,11 +48,7 @@ export class MikroOrmLogger extends DefaultLogger { } } - private createMessage( - namespace: LoggerNamespace, - messageArg: string, - context?: LogContext - ) { + private createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext) { const labels: LokiLabels = { service: 'ORM', }; diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index f9db6688..5ff5a53c 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -6,7 +6,7 @@ import * as express from 'express'; import * as jwt from 'jsonwebtoken'; import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticationInfo } from './authentication-info.js'; -import { ForbiddenException, UnauthorizedException } from '../../exceptions'; +import { ForbiddenException, UnauthorizedException } from '../../exceptions.js'; const JWKS_CACHE = true; const JWKS_RATE_LIMIT = true; @@ -52,16 +52,12 @@ const verifyJwtToken = expressjwt({ const issuer = (token.payload as JwtPayload).iss; - const idpConfig = Object.values(idpConfigs).find((config) => { - return config.issuer === issuer; - }); + const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); if (!idpConfig) { throw new Error('Issuer not accepted.'); } - const signingKey = await idpConfig.jwksClient.getSigningKey( - token.header.kid - ); + const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); if (!signingKey) { throw new Error('Signing key not found.'); } @@ -76,9 +72,7 @@ const verifyJwtToken = expressjwt({ /** * Get an object with information about the authenticated user from a given authenticated request. */ -function getAuthenticationInfo( - req: AuthenticatedRequest -): AuthenticationInfo | undefined { +function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined { if (!req.jwtPayload) { return; } @@ -106,11 +100,7 @@ function getAuthenticationInfo( * Add the AuthenticationInfo object with the information about the current authentication to the request in order * to avoid that the routers have to deal with the JWT token. */ -const addAuthenticationInfo = ( - req: AuthenticatedRequest, - res: express.Response, - next: express.NextFunction -) => { +const addAuthenticationInfo = (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => { req.auth = getAuthenticationInfo(req); next(); }; @@ -123,14 +113,9 @@ export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates * to true. */ -export const authorize = ( - accessCondition: (auth: AuthenticationInfo) => boolean -) => { - return ( - req: AuthenticatedRequest, - res: express.Response, - next: express.NextFunction - ): void => { +export const authorize = + (accessCondition: (auth: AuthenticationInfo) => boolean) => + (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction): void => { if (!req.auth) { throw new UnauthorizedException(); } else if (!accessCondition(req.auth)) { @@ -139,25 +124,18 @@ export const authorize = ( next(); } }; -}; /** * Middleware which rejects all unauthenticated users, but accepts all authenticated users. */ -export const authenticatedOnly = authorize((_) => { - return true; -}); +export const authenticatedOnly = authorize((_) => true); /** * Middleware which rejects requests from unauthenticated users or users that aren't students. */ -export const studentsOnly = authorize((auth) => { - return auth.accountType === 'student'; -}); +export const studentsOnly = authorize((auth) => auth.accountType === 'student'); /** * Middleware which rejects requests from unauthenticated users or users that aren't teachers. */ -export const teachersOnly = authorize((auth) => { - return auth.accountType === 'teacher'; -}); +export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); diff --git a/backend/src/mikro-orm.config.ts b/backend/src/mikro-orm.config.ts index f9629bef..c9cf6ed9 100644 --- a/backend/src/mikro-orm.config.ts +++ b/backend/src/mikro-orm.config.ts @@ -24,6 +24,7 @@ import { LearningPath } from './entities/content/learning-path.entity.js'; import { Answer } from './entities/questions/answer.entity.js'; import { Question } from './entities/questions/question.entity.js'; +import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js'; const entities = [ User, @@ -47,14 +48,13 @@ function config(testingMode: boolean = false): Options { return { driver: SqliteDriver, dbName: getEnvVar(EnvVars.DbName), + subscribers: [new SqliteAutoincrementSubscriber()], entities: entities, // EntitiesTs: entitiesTs, // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION) // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint) - dynamicImportProvider: (id) => { - return import(id); - }, + dynamicImportProvider: (id) => import(id), }; } @@ -70,9 +70,7 @@ function config(testingMode: boolean = false): Options { // Logging debug: LOG_LEVEL === 'debug', - loggerFactory: (options: LoggerOptions) => { - return new MikroOrmLogger(options); - }, + loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options), }; } diff --git a/backend/src/orm.ts b/backend/src/orm.ts index 88decd92..93feea7a 100644 --- a/backend/src/orm.ts +++ b/backend/src/orm.ts @@ -28,9 +28,7 @@ export async function initORM(testingMode: boolean = false) { } export function forkEntityManager(): EntityManager { if (!orm) { - throw Error( - 'Accessing the Entity Manager before the ORM is fully initialized.' - ); + throw Error('Accessing the Entity Manager before the ORM is fully initialized.'); } return orm.em.fork(); } diff --git a/backend/src/routes/learningObjects.ts b/backend/src/routes/learning-objects.ts similarity index 50% rename from backend/src/routes/learningObjects.ts rename to backend/src/routes/learning-objects.ts index 416602b5..b731fe69 100644 --- a/backend/src/routes/learningObjects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,8 +1,5 @@ import express from 'express'; -import { - getAllLearningObjects, - getLearningObject, -} from '../controllers/learningObjects.js'; +import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; const router = express.Router(); @@ -24,4 +21,16 @@ router.get('/', getAllLearningObjects); // Example: http://localhost:3000/learningObject/un_ai7 router.get('/:hruid', getLearningObject); +// Parameter: hruid of learning object +// Query: language, version (optional) +// Route to fetch the HTML rendering of one learning object based on its hruid. +// Example: http://localhost:3000/learningObject/un_ai7/html +router.get('/:hruid/html', getLearningObjectHTML); + +// Parameter: hruid of learning object, name of attachment. +// Query: language, version (optional). +// Route to get the raw data of the attachment for one learning object based on its hruid. +// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png +router.get('/:hruid/html/:attachmentName', getAttachment); + export default router; diff --git a/backend/src/routes/learningPaths.ts b/backend/src/routes/learning-paths.ts similarity index 91% rename from backend/src/routes/learningPaths.ts rename to backend/src/routes/learning-paths.ts index ce580745..efe17312 100644 --- a/backend/src/routes/learningPaths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,5 @@ import express from 'express'; -import { getLearningPaths } from '../controllers/learningPaths.js'; +import { getLearningPaths } from '../controllers/learning-paths.js'; const router = express.Router(); diff --git a/backend/src/routes/question.ts b/backend/src/routes/question.ts index f683d998..2e5db624 100644 --- a/backend/src/routes/question.ts +++ b/backend/src/routes/question.ts @@ -15,8 +15,7 @@ router.get('/:id', (req, res) => { student: '0', group: '0', time: new Date(2025, 1, 1), - content: - 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', + content: 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', learningObject: '0', links: { self: `${req.baseUrl}/${req.params.id}`, diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts new file mode 100644 index 00000000..d40b54c9 --- /dev/null +++ b/backend/src/routes/router.ts @@ -0,0 +1,35 @@ +import { Response, Router } from 'express'; +import studentRouter from './student'; +import groupRouter from './group'; +import assignmentRouter from './assignment'; +import submissionRouter from './submission'; +import classRouter from './class'; +import questionRouter from './question'; +import authRouter from './auth'; +import themeRoutes from './themes'; +import learningPathRoutes from './learning-paths'; +import learningObjectRoutes from './learning-objects'; +import { getLogger, Logger } from '../logging/initalize'; + +const router = Router(); +const logger: Logger = getLogger(); + +router.get('/', (_, res: Response) => { + logger.debug('GET /'); + res.json({ + message: 'Hello Dwengo!🚀', + }); +}); + +router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); +router.use('/group', groupRouter /* #swagger.tags = ['Group'] */); +router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); +router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); +router.use('/class', classRouter /* #swagger.tags = ['Class'] */); +router.use('/question', questionRouter /* #swagger.tags = ['Question'] */); +router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); +router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); +router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); +router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); + +export default router; diff --git a/backend/src/services/learning-objects/attachment-service.ts b/backend/src/services/learning-objects/attachment-service.ts new file mode 100644 index 00000000..aacc7187 --- /dev/null +++ b/backend/src/services/learning-objects/attachment-service.ts @@ -0,0 +1,23 @@ +import { getAttachmentRepository } from '../../data/repositories.js'; +import { Attachment } from '../../entities/content/attachment.entity.js'; +import { LearningObjectIdentifier } from '../../interfaces/learning-content.js'; + +const attachmentService = { + getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise { + const attachmentRepo = getAttachmentRepository(); + + if (learningObjectId.version) { + return attachmentRepo.findByLearningObjectIdAndName( + { + hruid: learningObjectId.hruid, + language: learningObjectId.language, + version: learningObjectId.version, + }, + attachmentName + ); + } + return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName); + }, +}; + +export default attachmentService; diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts new file mode 100644 index 00000000..bab0b9b1 --- /dev/null +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -0,0 +1,115 @@ +import { LearningObjectProvider } from './learning-object-provider.js'; +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js'; +import { Language } from '../../entities/content/language.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { getUrlStringForLearningObject } from '../../util/links.js'; +import processingService from './processing/processing-service.js'; +import { NotFoundError } from '@mikro-orm/core'; +import learningObjectService from './learning-object-service.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null { + if (!learningObject) { + return null; + } + return { + key: learningObject.hruid, + _id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field. + uuid: learningObject.uuid, + language: learningObject.language, + version: learningObject.version, + title: learningObject.title, + description: learningObject.description, + htmlUrl: getUrlStringForLearningObject(learningObject), + available: learningObject.available, + contentType: learningObject.contentType, + contentLocation: learningObject.contentLocation, + difficulty: learningObject.difficulty || 1, + estimatedTime: learningObject.estimatedTime, + keywords: learningObject.keywords, + educationalGoals: learningObject.educationalGoals, + returnValue: { + callback_url: learningObject.returnValue.callbackUrl, + callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), + }, + skosConcepts: learningObject.skosConcepts, + targetAges: learningObject.targetAges || [], + teacherExclusive: learningObject.teacherExclusive, + }; +} + +function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + + return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); +} + +/** + * Service providing access to data about learning objects from the database + */ +const databaseLearningObjectProvider: LearningObjectProvider = { + /** + * Fetches a single learning object by its HRUID + */ + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + const learningObject = await findLearningObjectEntityById(id); + return convertLearningObject(learningObject); + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + + const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); + if (!learningObject) { + return null; + } + return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id)); + }, + + /** + * Fetch the HRUIDs of all learning objects on this path. + */ + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); + if (!learningPath) { + throw new NotFoundError('The learning path with the given ID could not be found.'); + } + return learningPath.nodes.map((it) => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user. + }, + + /** + * Fetch the full metadata of all learning objects on this path. + */ + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); + if (!learningPath) { + throw new NotFoundError('The learning path with the given ID could not be found.'); + } + const learningObjects = await Promise.all( + learningPath.nodes.map((it) => { + const learningObject = learningObjectService.getLearningObjectById({ + hruid: it.learningObjectHruid, + language: it.language, + version: it.version, + }); + if (learningObject === null) { + logger.warn(`WARN: Learning object corresponding with node ${it} not found!`); + } + return learningObject; + }) + ); + return learningObjects.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 new file mode 100644 index 00000000..37e68c07 --- /dev/null +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -0,0 +1,138 @@ +import { DWENGO_API_BASE } from '../../config.js'; +import { fetchWithLogging } from '../../util/apiHelper.js'; +import { + FilteredLearningObject, + LearningObjectIdentifier, + LearningObjectMetadata, + LearningObjectNode, + LearningPathIdentifier, + LearningPathResponse, +} from '../../interfaces/learning-content.js'; +import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +/** + * Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which + * our API should return. + * @param data + */ +function filterData(data: LearningObjectMetadata): FilteredLearningObject { + return { + key: data.hruid, // Hruid learningObject (not path) + _id: data._id, + uuid: data.uuid, + version: data.version, + title: data.title, + htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content + language: data.language, + difficulty: data.difficulty, + estimatedTime: data.estimated_time, + available: data.available, + teacherExclusive: data.teacher_exclusive, + educationalGoals: data.educational_goals, // List with learningObjects + keywords: data.keywords, // For search + description: data.description, // For search (not an actual description) + targetAges: data.target_ages, + contentType: data.content_type, // Markdown, image, audio, etc. + contentLocation: data.content_location, // If content type extern + skosConcepts: data.skos_concepts, + returnValue: data.return_value, // Callback response information + }; +} + +/** + * Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs) + */ +async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise { + try { + const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths( + [learningPathId.hruid], + learningPathId.language, + `Learning path for HRUID "${learningPathId.hruid}"` + ); + + if (!learningPathResponse.success || !learningPathResponse.data?.length) { + logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`); + return []; + } + + const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; + + if (!full) { + return nodes.map((node) => node.learningobject_hruid); + } + + const objects = await Promise.all( + nodes.map(async (node) => + dwengoApiLearningObjectProvider.getLearningObjectById({ + hruid: node.learningobject_hruid, + language: learningPathId.language, + }) + ) + ); + return objects.filter((obj): obj is FilteredLearningObject => obj !== null); + } catch (error) { + logger.error('❌ Error fetching learning objects:', error); + return []; + } +} + +const dwengoApiLearningObjectProvider: LearningObjectProvider = { + /** + * Fetches a single learning object by its HRUID + */ + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; + const metadata = await fetchWithLogging( + metadataUrl, + `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, + { + params: id, + } + ); + + if (!metadata || typeof metadata !== 'object') { + logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); + return null; + } + + return filterData(metadata); + }, + + /** + * Fetch full learning object data (metadata) + */ + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + return (await fetchLearningObjects(id, true)) as FilteredLearningObject[]; + }, + + /** + * Fetch only learning object HRUIDs + */ + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + return (await fetchLearningObjects(id, false)) as string[]; + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects + * from the Dwengo API, this means passing through the HTML rendering from there. + */ + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; + const html = await fetchWithLogging(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { + params: id, + }); + + if (!html) { + logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); + return null; + } + + return html; + }, +}; + +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 new file mode 100644 index 00000000..81b4d228 --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -0,0 +1,23 @@ +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; + +export interface LearningObjectProvider { + /** + * Fetches a single learning object by its HRUID + */ + getLearningObjectById(id: LearningObjectIdentifier): Promise; + + /** + * Fetch full learning object data (metadata) + */ + getLearningObjectsFromPath(id: LearningPathIdentifier): Promise; + + /** + * Fetch only learning object HRUIDs + */ + getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise; + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + getLearningObjectHTML(id: LearningObjectIdentifier): Promise; +} diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts new file mode 100644 index 00000000..8289660b --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -0,0 +1,47 @@ +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import databaseLearningObjectProvider from './database-learning-object-provider.js'; + +function getProvider(id: LearningObjectIdentifier): LearningObjectProvider { + if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) { + return databaseLearningObjectProvider; + } + return dwengoApiLearningObjectProvider; +} + +/** + * Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api) + */ +const learningObjectService = { + /** + * Fetches a single learning object by its HRUID + */ + getLearningObjectById(id: LearningObjectIdentifier): Promise { + return getProvider(id).getLearningObjectById(id); + }, + + /** + * Fetch full learning object data (metadata) + */ + getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + return getProvider(id).getLearningObjectsFromPath(id); + }, + + /** + * Fetch only learning object HRUIDs + */ + getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + return getProvider(id).getLearningObjectIdsFromPath(id); + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + return getProvider(id).getLearningObjectHTML(id); + }, +}; + +export default learningObjectService; diff --git a/backend/src/services/learning-objects/processing/audio/audio-processor.ts b/backend/src/services/learning-objects/processing/audio/audio-processor.ts new file mode 100644 index 00000000..592669d5 --- /dev/null +++ b/backend/src/services/learning-objects/processing/audio/audio-processor.ts @@ -0,0 +1,25 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js + * + * WARNING: The support for audio learning objects is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { type } from 'node:os'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class AudioProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.AUDIO_MPEG); + } + + protected renderFn(audioUrl: string): string { + return DOMPurify.sanitize(``); + } +} + +export default AudioProcessor; diff --git a/backend/src/services/learning-objects/processing/content-type.ts b/backend/src/services/learning-objects/processing/content-type.ts new file mode 100644 index 00000000..2ea44246 --- /dev/null +++ b/backend/src/services/learning-objects/processing/content-type.ts @@ -0,0 +1,18 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js + */ + +enum DwengoContentType { + TEXT_PLAIN = 'text/plain', + TEXT_MARKDOWN = 'text/markdown', + IMAGE_BLOCK = 'image/image-block', + IMAGE_INLINE = 'image/image', + AUDIO_MPEG = 'audio/mpeg', + APPLICATION_PDF = 'application/pdf', + EXTERN = 'extern', + BLOCKLY = 'blockly', + GIFT = 'text/gift', + CT_SCHEMA = 'text/ct-schema', +} + +export { DwengoContentType }; diff --git a/backend/src/services/learning-objects/processing/extern/extern-processor.ts b/backend/src/services/learning-objects/processing/extern/extern-processor.ts new file mode 100644 index 00000000..453e998b --- /dev/null +++ b/backend/src/services/learning-objects/processing/extern/extern-processor.ts @@ -0,0 +1,40 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js + * + * WARNING: The support for external content is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { ProcessingError } from '../processing-error.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class ExternProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.EXTERN); + } + + override renderFn(externURL: string) { + if (!isValidHttpUrl(externURL)) { + throw new ProcessingError('The url is not valid: ' + externURL); + } + + // If a seperate youtube-processor would be added, this code would need to move to that processor + // Converts youtube urls to youtube-embed urls + const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL); + if (match) { + externURL = match[1] + 'embed/' + match[2]; + } + + return DOMPurify.sanitize( + ` +
+ +
`, + { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] } + ); + } +} + +export default ExternProcessor; diff --git a/backend/src/services/learning-objects/processing/gift/gift-processor.ts b/backend/src/services/learning-objects/processing/gift/gift-processor.ts new file mode 100644 index 00000000..5396236a --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/gift-processor.ts @@ -0,0 +1,61 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { GIFTQuestion, parse } from 'gift-pegjs'; +import { DwengoContentType } from '../content-type.js'; +import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js'; +import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js'; +import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js'; +import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js'; +import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js'; +import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js'; +import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js'; +import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js'; +import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js'; +import { StringProcessor } from '../string-processor.js'; + +class GiftProcessor extends StringProcessor { + private renderers: RendererMap = { + Category: new CategoryQuestionRenderer(), + Description: new DescriptionQuestionRenderer(), + Essay: new EssayQuestionRenderer(), + Matching: new MatchingQuestionRenderer(), + Numerical: new NumericalQuestionRenderer(), + Short: new ShortQuestionRenderer(), + TF: new TrueFalseQuestionRenderer(), + MC: new MultipleChoiceQuestionRenderer(), + }; + + constructor() { + super(DwengoContentType.GIFT); + } + + override renderFn(giftString: string) { + const quizQuestions: GIFTQuestion[] = parse(giftString); + + let html = "
\n"; + let i = 1; + for (const question of quizQuestions) { + html += `
\n`; + html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation. + html += `
\n`; + i++; + } + html += '
\n'; + + return DOMPurify.sanitize(html); + } + + private renderQuestion(question: T, questionNumber: number): string { + const renderer = this.renderers[question.type] as GIFTQuestionRenderer; + return renderer.render(question, questionNumber); + } +} + +type RendererMap = { + [K in GIFTQuestion['type']]: GIFTQuestionRenderer>; +}; + +export default GiftProcessor; diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts new file mode 100644 index 00000000..507e5ada --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Category } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class CategoryQuestionRenderer extends GIFTQuestionRenderer { + render(question: Category, questionNumber: number): string { + throw new ProcessingError("The question type 'Category' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts new file mode 100644 index 00000000..0238d76a --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Description } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class DescriptionQuestionRenderer extends GIFTQuestionRenderer { + render(question: Description, questionNumber: number): string { + throw new ProcessingError("The question type 'Description' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts new file mode 100644 index 00000000..987fbcaf --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts @@ -0,0 +1,16 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Essay } from 'gift-pegjs'; + +export class EssayQuestionRenderer extends GIFTQuestionRenderer { + render(question: Essay, questionNumber: number): string { + let renderedHtml = ''; + if (question.title) { + renderedHtml += `

${question.title}

\n`; + } + if (question.stem) { + renderedHtml += `

${question.stem.text}

\n`; + } + renderedHtml += `\n`; + return renderedHtml; + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts new file mode 100644 index 00000000..41ab5ba2 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts @@ -0,0 +1,14 @@ +import { GIFTQuestion } from 'gift-pegjs'; + +/** + * Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML. + */ +export abstract class GIFTQuestionRenderer { + /** + * Render the given question to HTML. + * @param question The question. + * @param questionNumber The index number of the question. + * @returns The question rendered as HTML. + */ + abstract render(question: T, questionNumber: number): string; +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts new file mode 100644 index 00000000..bb6e9737 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Matching } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class MatchingQuestionRenderer extends GIFTQuestionRenderer { + render(question: Matching, questionNumber: number): string { + throw new ProcessingError("The question type 'Matching' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts new file mode 100644 index 00000000..39846c51 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts @@ -0,0 +1,23 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { MultipleChoice } from 'gift-pegjs'; + +export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer { + render(question: MultipleChoice, questionNumber: number): string { + let renderedHtml = ''; + if (question.title) { + renderedHtml += `

${question.title}

\n`; + } + if (question.stem) { + renderedHtml += `

${question.stem.text}

\n`; + } + let i = 0; + for (const choice of question.choices) { + renderedHtml += `
\n`; + renderedHtml += ` \n`; + renderedHtml += ` \n`; + renderedHtml += `
\n`; + i++; + } + return renderedHtml; + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts new file mode 100644 index 00000000..32fdb06e --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Numerical } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class NumericalQuestionRenderer extends GIFTQuestionRenderer { + render(question: Numerical, questionNumber: number): string { + throw new ProcessingError("The question type 'Numerical' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts new file mode 100644 index 00000000..5a63531f --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { ShortAnswer } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class ShortQuestionRenderer extends GIFTQuestionRenderer { + render(question: ShortAnswer, questionNumber: number): string { + throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts new file mode 100644 index 00000000..98148130 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { TrueFalse } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer { + render(question: TrueFalse, questionNumber: number): string { + throw new ProcessingError("The question type 'TrueFalse' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/image/block-image-processor.ts b/backend/src/services/learning-objects/processing/image/block-image-processor.ts new file mode 100644 index 00000000..f4f8a773 --- /dev/null +++ b/backend/src/services/learning-objects/processing/image/block-image-processor.ts @@ -0,0 +1,19 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js + */ + +import InlineImageProcessor from './inline-image-processor.js'; +import DOMPurify from 'isomorphic-dompurify'; + +class BlockImageProcessor extends InlineImageProcessor { + constructor() { + super(); + } + + override renderFn(imageUrl: string) { + const inlineHtml = super.render(imageUrl); + return DOMPurify.sanitize(`
${inlineHtml}
`); + } +} + +export default BlockImageProcessor; diff --git a/backend/src/services/learning-objects/processing/image/inline-image-processor.ts b/backend/src/services/learning-objects/processing/image/inline-image-processor.ts new file mode 100644 index 00000000..478ce326 --- /dev/null +++ b/backend/src/services/learning-objects/processing/image/inline-image-processor.ts @@ -0,0 +1,24 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { ProcessingError } from '../processing-error.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { StringProcessor } from '../string-processor.js'; + +class InlineImageProcessor extends StringProcessor { + constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) { + super(contentType); + } + + override renderFn(imageUrl: string) { + if (!isValidHttpUrl(imageUrl)) { + throw new ProcessingError(`Image URL is invalid: ${imageUrl}`); + } + return DOMPurify.sanitize(``); + } +} + +export default InlineImageProcessor; diff --git a/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts new file mode 100644 index 00000000..d1c797be --- /dev/null +++ b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts @@ -0,0 +1,109 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!] + */ +import PdfProcessor from '../pdf/pdf-processor.js'; +import AudioProcessor from '../audio/audio-processor.js'; +import ExternProcessor from '../extern/extern-processor.js'; +import InlineImageProcessor from '../image/inline-image-processor.js'; +import * as marked from 'marked'; +import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js'; +import { ProcessingError } from '../processing-error.js'; +import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js'; +import { Language } from '../../../../entities/content/language.js'; + +import Image = marked.Tokens.Image; +import Heading = marked.Tokens.Heading; +import Link = marked.Tokens.Link; +import RendererObject = marked.RendererObject; + +const prefixes = { + learningObject: '@learning-object', + pdf: '@pdf', + audio: '@audio', + extern: '@extern', + video: '@youtube', + notebook: '@notebook', + blockly: '@blockly', +}; + +function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier { + const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/'); + return { + hruid, + language: language as Language, + version: parseInt(version), + }; +} + +/** + * An extension for the renderer of the Marked Markdown renderer which adds support for + * - a custom heading, + * - links to other learning objects, + * - embeddings of other learning objects. + */ +const dwengoMarkedRenderer: RendererObject = { + heading(heading: Heading): string { + const text = heading.text; + const level = heading.depth; + const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-'); + + return ( + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` ${text}\n` + + `\n` + ); + }, + + // When the syntax for a link is used => [text](href "title") + // Render a custom link when the prefix for a learning object is used. + link(link: Link): string { + const href = link.href; + const title = link.title || ''; + const text = marked.parseInline(link.text); // There could for example be an image in the link. + + if (href.startsWith(prefixes.learningObject)) { + // Link to learning-object + const learningObjectId = extractLearningObjectIdFromHref(href); + return `${text}`; + } + // Any other link + if (!isValidHttpUrl(href)) { + throw new ProcessingError('Link is not a valid HTTP URL!'); + } + // + return `${text}`; + }, + + // When the syntax for an image is used => ![text](href "title") + // Render a learning object, pdf, audio or video if a prefix is used. + image(img: Image): string { + const href = img.href; + if (href.startsWith(prefixes.learningObject)) { + // Embedded learning-object + const learningObjectId = extractLearningObjectIdFromHref(href); + return ` + + `; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!) + } else if (href.startsWith(prefixes.pdf)) { + // Embedded pdf + const proc = new PdfProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } else if (href.startsWith(prefixes.audio)) { + // Embedded audio + const proc = new AudioProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) { + // Embedded youtube video or notebook (or other extern content) + const proc = new ExternProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } + // Embedded image + const proc = new InlineImageProcessor(); + return proc.render(href); + }, +}; + +export default dwengoMarkedRenderer; diff --git a/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts b/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts new file mode 100644 index 00000000..1de6b3d5 --- /dev/null +++ b/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts @@ -0,0 +1,39 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js + */ + +import { marked } from 'marked'; +import InlineImageProcessor from '../image/inline-image-processor.js'; +import { DwengoContentType } from '../content-type.js'; +import dwengoMarkedRenderer from './dwengo-marked-renderer.js'; +import { StringProcessor } from '../string-processor.js'; +import { ProcessingError } from '../processing-error.js'; + +class MarkdownProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.TEXT_MARKDOWN); + } + + override renderFn(mdText: string) { + let html = ''; + try { + marked.use({ renderer: dwengoMarkedRenderer }); + html = marked(mdText, { async: false }); + html = this.replaceLinks(html); // Replace html image links path + } catch (e: any) { + throw new ProcessingError(e.message); + } + return html; + } + + replaceLinks(html: string) { + const proc = new InlineImageProcessor(); + html = html.replace( + //g, + (match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src) + ); + return html; + } +} + +export { MarkdownProcessor }; diff --git a/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts b/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts new file mode 100644 index 00000000..26cb4d94 --- /dev/null +++ b/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts @@ -0,0 +1,32 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js + * + * WARNING: The support for PDF learning objects is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { ProcessingError } from '../processing-error.js'; +import { StringProcessor } from '../string-processor.js'; + +class PdfProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.APPLICATION_PDF); + } + + override renderFn(pdfUrl: string) { + if (!isValidHttpUrl(pdfUrl)) { + throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`); + } + + return DOMPurify.sanitize( + ` + + `, + { ADD_TAGS: ['embed'] } + ); + } +} + +export default PdfProcessor; diff --git a/backend/src/services/learning-objects/processing/processing-error.ts b/backend/src/services/learning-objects/processing/processing-error.ts new file mode 100644 index 00000000..b51f2a3a --- /dev/null +++ b/backend/src/services/learning-objects/processing/processing-error.ts @@ -0,0 +1,5 @@ +export class ProcessingError extends Error { + constructor(error: string) { + super(error); + } +} diff --git a/backend/src/services/learning-objects/processing/processing-service.ts b/backend/src/services/learning-objects/processing/processing-service.ts new file mode 100644 index 00000000..a6c662cc --- /dev/null +++ b/backend/src/services/learning-objects/processing/processing-service.ts @@ -0,0 +1,83 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js + */ + +import BlockImageProcessor from './image/block-image-processor.js'; +import InlineImageProcessor from './image/inline-image-processor.js'; +import { MarkdownProcessor } from './markdown/markdown-processor.js'; +import TextProcessor from './text/text-processor.js'; +import AudioProcessor from './audio/audio-processor.js'; +import PdfProcessor from './pdf/pdf-processor.js'; +import ExternProcessor from './extern/extern-processor.js'; +import GiftProcessor from './gift/gift-processor.js'; +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; +import Processor from './processor.js'; +import { DwengoContentType } from './content-type.js'; +import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js'; +import { Language } from '../../../entities/content/language.js'; +import { replaceAsync } from '../../../util/async.js'; + +const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = //g; +const LEARNING_OBJECT_DOES_NOT_EXIST = "
"; + +class ProcessingService { + private processors!: Map>; + + constructor() { + const processors = [ + new InlineImageProcessor(), + new BlockImageProcessor(), + new MarkdownProcessor(), + new TextProcessor(), + new AudioProcessor(), + new PdfProcessor(), + new ExternProcessor(), + new GiftProcessor(), + ]; + + this.processors = new Map(processors.map((processor) => [processor.contentType, processor])); + } + + /** + * Render the given learning object. + * @param learningObject The learning object to render + * @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and + * returns the corresponding learning object. This is used to fetch learning + * objects embedded into this one. + * If this argument is omitted, embedded learning objects will be represented + * by placeholders. + * @returns Rendered HTML for this LearningObject as a string. + */ + async render( + learningObject: LearningObject, + fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise + ): Promise { + const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); + if (fetchEmbeddedLearningObjects) { + // Replace all embedded learning objects. + return replaceAsync( + html, + EMBEDDED_LEARNING_OBJECT_PLACEHOLDER, + async (_, hruid: string, language: string, version: string): Promise => { + // Fetch the embedded learning object... + const learningObject = await fetchEmbeddedLearningObjects({ + hruid, + language: language as Language, + version: parseInt(version), + }); + + // If it does not exist, replace it by a placeholder. + if (!learningObject) { + return LEARNING_OBJECT_DOES_NOT_EXIST; + } + + // ... and render it. + return this.render(learningObject); + } + ); + } + return html; + } +} + +export default new ProcessingService(); diff --git a/backend/src/services/learning-objects/processing/processor.ts b/backend/src/services/learning-objects/processing/processor.ts new file mode 100644 index 00000000..a11c4416 --- /dev/null +++ b/backend/src/services/learning-objects/processing/processor.ts @@ -0,0 +1,61 @@ +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; +import { ProcessingError } from './processing-error.js'; +import { DwengoContentType } from './content-type.js'; + +/** + * Abstract base class for all processors. + * Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML. + * + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js + */ +abstract class Processor { + protected constructor(public contentType: DwengoContentType) {} + + /** + * Render the given object. + * + * @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this + * Processor is responsible. + * @return Rendered HTML-string + * @throws ProcessingError if the rendering fails. + */ + render(toRender: T): string { + return this.renderFn(toRender); + } + + /** + * Render a learning object with the content type for which this processor is responsible. + * @param toRender + */ + renderLearningObject(toRender: LearningObject): string { + if (toRender.contentType !== this.contentType) { + throw new ProcessingError( + `Unsupported content type: ${toRender.contentType}. + This processor is only responsible for content of type ${this.contentType}.` + ); + } + return this.renderLearningObjectFn(toRender); + } + + /** + * Function which actually renders the content. + * + * @param toRender Content to be rendered + * @return Rendered HTML as a string + * @protected + */ + protected abstract renderFn(toRender: T): string; + + /** + * Function which actually executes the rendering of a learning object. + * + * When implementing this function, we may assume that we are responsible for the content type of the learning + * object. + * + * @param toRender Learning object to render + * @protected + */ + protected abstract renderLearningObjectFn(toRender: LearningObject): string; +} + +export default Processor; diff --git a/backend/src/services/learning-objects/processing/string-processor.ts b/backend/src/services/learning-objects/processing/string-processor.ts new file mode 100644 index 00000000..f4734af9 --- /dev/null +++ b/backend/src/services/learning-objects/processing/string-processor.ts @@ -0,0 +1,19 @@ +import Processor from './processor.js'; +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; + +export abstract class StringProcessor extends Processor { + /** + * Function which actually executes the rendering of a learning object. + * By default, this just means rendering the content in the content property of the learning object (interpreted + * as string) + * + * When implementing this function, we may assume that we are responsible for the content type of the learning + * object. + * + * @param toRender Learning object to render + * @protected + */ + protected renderLearningObjectFn(toRender: LearningObject): string { + return this.render(toRender.content.toString('ascii')); + } +} diff --git a/backend/src/services/learning-objects/processing/text/text-processor.ts b/backend/src/services/learning-objects/processing/text/text-processor.ts new file mode 100644 index 00000000..db6d37df --- /dev/null +++ b/backend/src/services/learning-objects/processing/text/text-processor.ts @@ -0,0 +1,20 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class TextProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.TEXT_PLAIN); + } + + override renderFn(text: string) { + // Sanitize plain text to prevent xss. + return DOMPurify.sanitize(text); + } +} + +export default TextProcessor; diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts new file mode 100644 index 00000000..fa8f42c6 --- /dev/null +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -0,0 +1,190 @@ +import { LearningPathProvider } from './learning-path-provider.js'; +import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js'; +import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; +import { getLearningPathRepository } from '../../data/repositories.js'; +import { Language } from '../../entities/content/language.js'; +import learningObjectService from '../learning-objects/learning-object-service.js'; +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js'; + +/** + * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its + * corresponding learning object. + * @param nodes The nodes to find the learning object for. + */ +async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise> { + // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to + // Its corresponding learning object. + const nullableNodesToLearningObjects = new Map( + await Promise.all( + nodes.map((node) => + learningObjectService + .getLearningObjectById({ + hruid: node.learningObjectHruid, + version: node.version, + language: node.language, + }) + .then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject]) + ) + ) + ); + if (nullableNodesToLearningObjects.values().some((it) => it === null)) { + throw new Error('At least one of the learning objects on this path could not be found.'); + } + return nullableNodesToLearningObjects as Map; +} + +/** + * Convert the given learning path entity to an object which conforms to the learning path content. + */ +async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise { + const nodesToLearningObjects: Map = await getLearningObjectsForNodes(learningPath.nodes); + + const targetAges = nodesToLearningObjects + .values() + .flatMap((it) => it.targetAges || []) + .toArray(); + + const keywords = nodesToLearningObjects + .values() + .flatMap((it) => it.keywords || []) + .toArray(); + + const image = learningPath.image ? learningPath.image.toString('base64') : undefined; + + const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); + + return { + _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. + __order: order, + hruid: learningPath.hruid, + language: learningPath.language, + description: learningPath.description, + image: image, + title: learningPath.title, + nodes: convertedNodes, + num_nodes: learningPath.nodes.length, + num_nodes_left: convertedNodes.filter((it) => !it.done).length, + keywords: keywords.join(' '), + target_ages: targetAges, + max_age: Math.max(...targetAges), + min_age: Math.min(...targetAges), + }; +} + +/** + * Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding + * learning objects into a list of learning path nodes as they should be represented in the API. + * @param nodesToLearningObjects + * @param personalizedFor + */ +async function convertNodes( + nodesToLearningObjects: Map, + personalizedFor?: PersonalizationTarget +): Promise { + const nodesPromise = nodesToLearningObjects + .entries() + .map(async (entry) => { + const [node, learningObject] = entry; + const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; + return { + _id: learningObject.uuid, + language: learningObject.language, + start_node: node.startNode, + created_at: node.createdAt.toISOString(), + updatedAt: node.updatedAt.toISOString(), + learningobject_hruid: node.learningObjectHruid, + version: learningObject.version, + transitions: node.transitions + .filter( + (trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible. + ) + .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition + }; + }) + .toArray(); + return await Promise.all(nodesPromise); +} + +/** + * Helper method to convert a json string to an object, or null if it is undefined. + */ +function optionalJsonStringToObject(jsonString?: string): object | null { + if (!jsonString) { + return null; + } + return JSON.parse(jsonString); +} + +/** + * Helper function which converts a transition in the database representation to a transition in the representation + * the Dwengo API uses. + * + * @param transition + * @param index + * @param nodesToLearningObjects + */ +function convertTransition( + transition: LearningPathTransition, + index: number, + nodesToLearningObjects: Map +): Transition { + const nextNode = nodesToLearningObjects.get(transition.next); + if (!nextNode) { + throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`); + } else { + return { + _id: '' + 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. + next: { + _id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility. + hruid: transition.next.learningObjectHruid, + language: nextNode.language, + version: nextNode.version, + }, + }; + } +} + +/** + * Service providing access to data about learning paths from the database. + */ +const databaseLearningPathProvider: LearningPathProvider = { + /** + * Fetch the learning paths with the given hruids from the database. + */ + async fetchLearningPaths( + hruids: string[], + language: Language, + source: string, + personalizedFor?: PersonalizationTarget + ): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( + (learningPath) => learningPath !== null + ); + const filteredLearningPaths = await Promise.all( + learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) + ); + + return { + success: filteredLearningPaths.length > 0, + data: await Promise.all(filteredLearningPaths), + source, + }; + }, + + /** + * Search learning paths in the database using the given search string. + */ + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + const learningPathRepo = getLearningPathRepository(); + + const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); + return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor))); + }, +}; + +export default databaseLearningPathProvider; 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 new file mode 100644 index 00000000..2b1d17a6 --- /dev/null +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -0,0 +1,50 @@ +import { fetchWithLogging } from '../../util/apiHelper.js'; +import { DWENGO_API_BASE } from '../../config.js'; +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import { LearningPathProvider } from './learning-path-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +const dwengoApiLearningPathProvider: LearningPathProvider = { + async fetchLearningPaths(hruids: string[], language: string, source: string): Promise { + if (hruids.length === 0) { + return { + success: false, + source, + data: null, + message: `No HRUIDs provided for ${source}.`, + }; + } + + const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; + const params = { pathIdList: JSON.stringify({ hruids }), language }; + + const learningPaths = await fetchWithLogging(apiUrl, `Learning paths for ${source}`, { params }); + + if (!learningPaths || learningPaths.length === 0) { + logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); + return { + success: false, + source, + data: [], + message: `No learning paths found for ${source}.`, + }; + } + + return { + success: true, + source, + data: learningPaths, + }; + }, + async searchLearningPaths(query: string, language: string): Promise { + const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; + const params = { all: query, language }; + + const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); + return searchResults ?? []; + }, +}; + +export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts new file mode 100644 index 00000000..a9175d13 --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -0,0 +1,90 @@ +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { Submission } from '../../entities/assignments/submission.entity.js'; +import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { JSONPath } from 'jsonpath-plus'; + +export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group }; + +/** + * Shortcut function to easily create a PersonalizationTarget object for a student by his/her username. + * @param username Username of the student we want to generate a personalized learning path for. + * If there is no student with this username, return undefined. + */ +export async function personalizedForStudent(username: string): Promise { + const student = await getStudentRepository().findByUsername(username); + if (student) { + return { + type: 'student', + student: student, + }; + } + return undefined; +} + +/** + * Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and + * group number. + * @param classId Id of the class in which this group was created + * @param assignmentNumber Number of the assignment for which this group was created + * @param groupNumber Number of the group for which we want to personalize the learning path. + */ +export async function personalizedForGroup( + classId: string, + assignmentNumber: number, + groupNumber: number +): Promise { + const clazz = await getClassRepository().findById(classId); + if (!clazz) { + return undefined; + } + const group = await getGroupRepository().findOne({ + assignment: { + within: clazz, + id: assignmentNumber, + }, + groupNumber: groupNumber, + }); + if (group) { + return { + type: 'group', + group: group, + }; + } + return undefined; +} + +/** + * Returns the last submission for the learning object associated with the given node and for the student or group + */ +export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise { + const submissionRepo = getSubmissionRepository(); + const learningObjectId: LearningObjectIdentifier = { + hruid: node.learningObjectHruid, + language: node.language, + version: node.version, + }; + if (pathFor.type === 'group') { + return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group); + } + return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student); +} + +/** + * Checks whether the condition of the given transaction is fulfilled by the given submission. + * @param transition + * @param submitted + */ +export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean { + if (transition.condition === 'true' || !transition.condition) { + return true; // If the transition is unconditional, we can go on. + } + if (submitted === null) { + return false; // If the transition is not unconditional and there was no submission, the transition is not possible. + } + const match = JSONPath({ path: transition.condition, json: { submission: submitted } }); + return match.length === 1; +} diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts new file mode 100644 index 00000000..5e2a09df --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -0,0 +1,18 @@ +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import { Language } from '../../entities/content/language.js'; +import { PersonalizationTarget } from './learning-path-personalization-util.js'; + +/** + * Generic interface for a service which provides access to learning paths from a data source. + */ +export interface LearningPathProvider { + /** + * Fetch the learning paths with the given hruids from the data source. + */ + fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise; + + /** + * Search learning paths in the data source using the given search string. + */ + searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise; +} diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts new file mode 100644 index 00000000..2fceb46c --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -0,0 +1,57 @@ +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +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 { Language } from '../../entities/content/language.js'; +import { PersonalizationTarget } from './learning-path-personalization-util.js'; + +const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix); +const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; + +/** + * Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) + */ +const learningPathService = { + /** + * Fetch the learning paths with the given hruids from the data source. + * @param hruids For each of the hruids, the learning path will be fetched. + * @param language This is the language each of the learning paths will use. + * @param source + * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. + */ + async fetchLearningPaths( + hruids: string[], + language: Language, + source: string, + personalizedFor?: PersonalizationTarget + ): Promise { + const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); + const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); + + const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor); + const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths( + nonUserContentHruids, + language, + source, + personalizedFor + ); + + const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); + + return { + data: result, + source: source, + success: userContentLearningPaths.success || nonUserContentLearningPaths.success, + }; + }, + + /** + * Search learning paths in the data source using the given search string. + */ + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor))); + return providerResponses.flat(); + }, +}; + +export default learningPathService; diff --git a/backend/src/services/learningObjects.ts b/backend/src/services/learningObjects.ts deleted file mode 100644 index 59cf6d31..00000000 --- a/backend/src/services/learningObjects.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { DWENGO_API_BASE } from '../config.js'; -import { fetchWithLogging } from '../util/apiHelper.js'; -import { - FilteredLearningObject, - LearningObjectMetadata, - LearningObjectNode, - LearningPathResponse, -} from '../interfaces/learningPath.js'; -import { fetchLearningPaths } from './learningPaths.js'; -import { getLogger, Logger } from '../logging/initalize.js'; - -const logger: Logger = getLogger(); - -function filterData( - data: LearningObjectMetadata, - htmlUrl: string -): FilteredLearningObject { - return { - key: data.hruid, // Hruid learningObject (not path) - _id: data._id, - uuid: data.uuid, - version: data.version, - title: data.title, - htmlUrl, // Url to fetch html content - language: data.language, - difficulty: data.difficulty, - estimatedTime: data.estimated_time, - available: data.available, - teacherExclusive: data.teacher_exclusive, - educationalGoals: data.educational_goals, // List with learningObjects - keywords: data.keywords, // For search - description: data.description, // For search (not an actual description) - targetAges: data.target_ages, - contentType: data.content_type, // Markdown, image, audio, etc. - contentLocation: data.content_location, // If content type extern - skosConcepts: data.skos_concepts, - returnValue: data.return_value, // Callback response information - }; -} - -/** - * Fetches a single learning object by its HRUID - */ -export async function getLearningObjectById( - hruid: string, - language: string -): Promise { - const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; - const metadata = await fetchWithLogging( - metadataUrl, - `Metadata for Learning Object HRUID "${hruid}" (language ${language})` - ); - - if (!metadata) { - logger.warn(`⚠️ WARNING: Learning object "${hruid}" not found.`); - return null; - } - - const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`; - return filterData(metadata, htmlUrl); -} - -/** - * Generic function to fetch learning objects (full data or just HRUIDs) - */ -async function fetchLearningObjects( - hruid: string, - full: boolean, - language: string -): Promise { - try { - const learningPathResponse: LearningPathResponse = - await fetchLearningPaths( - [hruid], - language, - `Learning path for HRUID "${hruid}"` - ); - - if ( - !learningPathResponse.success || - !learningPathResponse.data?.length - ) { - logger.warn( - `⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.` - ); - return []; - } - - const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; - - if (!full) { - return nodes.map((node) => { - return node.learningobject_hruid; - }); - } - - return await Promise.all( - nodes.map(async (node) => { - return getLearningObjectById( - node.learningobject_hruid, - language - ); - }) - ).then((objects) => { - return objects.filter((obj): obj is FilteredLearningObject => { - return obj !== null; - }); - }); - } catch (error) { - logger.error('❌ Error fetching learning objects:', error); - return []; - } -} - -/** - * Fetch full learning object data (metadata) - */ -export async function getLearningObjectsFromPath( - hruid: string, - language: string -): Promise { - return (await fetchLearningObjects( - hruid, - true, - language - )) as FilteredLearningObject[]; -} - -/** - * Fetch only learning object HRUIDs - */ -export async function getLearningObjectIdsFromPath( - hruid: string, - language: string -): Promise { - return (await fetchLearningObjects(hruid, false, language)) as string[]; -} diff --git a/backend/src/services/learningPaths.ts b/backend/src/services/learningPaths.ts deleted file mode 100644 index 52b168ee..00000000 --- a/backend/src/services/learningPaths.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { fetchWithLogging } from '../util/apiHelper.js'; -import { DWENGO_API_BASE } from '../config.js'; -import { - LearningPath, - LearningPathResponse, -} from '../interfaces/learningPath.js'; -import { getLogger, Logger } from '../logging/initalize.js'; - -const logger: Logger = getLogger(); - -export async function fetchLearningPaths( - hruids: string[], - language: string, - source: string -): Promise { - if (hruids.length === 0) { - return { - success: false, - source, - data: null, - message: `No HRUIDs provided for ${source}.`, - }; - } - - const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; - const params = { pathIdList: JSON.stringify({ hruids }), language }; - - const learningPaths = await fetchWithLogging( - apiUrl, - `Learning paths for ${source}`, - params - ); - - if (!learningPaths || learningPaths.length === 0) { - logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); - return { - success: false, - source, - data: [], - message: `No learning paths found for ${source}.`, - }; - } - - return { - success: true, - source, - data: learningPaths, - }; -} - -export async function searchLearningPaths( - query: string, - language: string -): Promise { - const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; - const params = { all: query, language }; - - const searchResults = await fetchWithLogging( - apiUrl, - `Search learning paths with query "${query}"`, - params - ); - return searchResults ?? []; -} diff --git a/backend/src/sqlite-autoincrement-workaround.ts b/backend/src/sqlite-autoincrement-workaround.ts new file mode 100644 index 00000000..a5c20dfd --- /dev/null +++ b/backend/src/sqlite-autoincrement-workaround.ts @@ -0,0 +1,41 @@ +import { EntityProperty, EventArgs, EventSubscriber } from '@mikro-orm/core'; + +/** + * The tests are ran on an in-memory SQLite database. However, SQLite does not allow fields which are part of composite + * primary keys to be autoincremented (while PostgreSQL, which we use in production, does). This Subscriber works around + * the issue by remembering the highest values for every autoincremented part of a primary key and assigning them when + * creating a new entity. + * + * However, it is important to note the following limitations: + * - this class can only be used for in-memory SQLite databases since the information on what the highest sequence + * number for each of the properties is, is only saved transiently. + * - automatically setting the generated "autoincremented" value for properties only works when the entity is created + * via an entityManager.create(...) or repo.create(...) method. Otherwise, onInit will not be called and therefore, + * the sequence number will not be filled in. + */ +export class SqliteAutoincrementSubscriber implements EventSubscriber { + private sequenceNumbersForEntityType: Map = new Map(); + + /** + * When an entity with an autoincremented property which is part of the composite private key is created, + * automatically fill this property so we won't face not-null-constraint exceptions when persisting it. + */ + onInit(args: EventArgs): void { + if (!args.meta.compositePK) { + return; // If there is not a composite primary key, autoincrement works fine with SQLite anyway. + } + + for (const prop of Object.values(args.meta.properties)) { + const property = prop as EntityProperty; + if (property.primary && property.autoincrement && !(args.entity as Record)[property.name]) { + // Obtain and increment sequence number of this entity. + const propertyKey = args.meta.class.name + '.' + property.name; + const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; + this.sequenceNumbersForEntityType.set(propertyKey, nextSeqNumber + 1); + + // Set the property accordingly. + (args.entity as Record)[property.name] = nextSeqNumber + 1; + } + } + } +} diff --git a/backend/src/util/apiHelper.ts b/backend/src/util/apiHelper.ts index 83c3e975..fff7a6a1 100644 --- a/backend/src/util/apiHelper.ts +++ b/backend/src/util/apiHelper.ts @@ -9,35 +9,34 @@ const logger: Logger = getLogger(); * * @param url The API endpoint to fetch from. * @param description A short description of what is being fetched (for logging). - * @param params + * @param options Contains further options such as params (the query params) and responseType (whether the response + * should be parsed as JSON ("json") or whether it should be returned as plain text ("text") * @returns The response data if successful, or null if an error occurs. */ export async function fetchWithLogging( url: string, description: string, - params?: Record + options?: { + params?: Record; + query?: Record; + responseType?: 'json' | 'text'; + } ): Promise { try { - const config: AxiosRequestConfig = params ? { params } : {}; - + const config: AxiosRequestConfig = options || {}; const response = await axios.get(url, config); return response.data; } catch (error: any) { if (error.response) { if (error.response.status === 404) { - logger.debug( - `❌ ERROR: ${description} not found (404) at "${url}".` - ); + logger.debug(`❌ ERROR: ${description} not found (404) at "${url}".`); } else { logger.debug( `❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")` ); } } else { - logger.debug( - `❌ ERROR: Network or unexpected error when fetching ${description}:`, - error.message - ); + logger.debug(`❌ ERROR: Network or unexpected error when fetching ${description}:`, error.message); } return null; } diff --git a/backend/src/util/async.ts b/backend/src/util/async.ts new file mode 100644 index 00000000..a5fc9b82 --- /dev/null +++ b/backend/src/util/async.ts @@ -0,0 +1,23 @@ +/** + * Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of + * the parts matched by a group in the regex as arguments. + * + * @param str The string where to replace the occurrences + * @param regex + * @param replacementFn + */ +export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise) { + const promises: Promise[] = []; + + // First run through matches: add all Promises resulting from the replacement function + str.replace(regex, (full, ...args) => { + promises.push(replacementFn(full, ...args)); + return full; + }); + + // Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner. + const replacements: string[] = await Promise.all(promises); + + // Second run through matches: Replace them by their previously computed replacements. + return str.replace(regex, () => replacements.pop()!); +} diff --git a/backend/src/util/envvars.ts b/backend/src/util/envvars.ts index 449c799e..115291af 100644 --- a/backend/src/util/envvars.ts +++ b/backend/src/util/envvars.ts @@ -15,33 +15,18 @@ export const EnvVars: { [key: string]: EnvVar } = { DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, + LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' }, + FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' }, + UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' }, IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, - IdpStudentClientId: { - key: STUDENT_IDP_PREFIX + 'CLIENT_ID', - required: true, - }, - IdpStudentJwksEndpoint: { - key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', - required: true, - }, + IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, + IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, IdpTeacherUrl: { key: TEACHER_IDP_PREFIX + 'URL', required: true }, - IdpTeacherClientId: { - key: TEACHER_IDP_PREFIX + 'CLIENT_ID', - required: true, - }, - IdpTeacherJwksEndpoint: { - key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', - required: true, - }, + IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true }, + IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' }, - CorsAllowedOrigins: { - key: CORS_PREFIX + 'ALLOWED_ORIGINS', - defaultValue: '', - }, - CorsAllowedHeaders: { - key: CORS_PREFIX + 'ALLOWED_HEADERS', - defaultValue: 'Authorization,Content-Type', - }, + CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' }, + CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' }, } as const; /** @@ -67,9 +52,7 @@ export function getNumericEnvVar(envVar: EnvVar): number { const valueString = getEnvVar(envVar); const value = parseInt(valueString); if (isNaN(value)) { - throw new Error( - `Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.` - ); + throw new Error(`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`); } else { return value; } diff --git a/backend/src/util/links.ts b/backend/src/util/links.ts new file mode 100644 index 00000000..73e27965 --- /dev/null +++ b/backend/src/util/links.ts @@ -0,0 +1,26 @@ +import { LearningObjectIdentifier } from '../interfaces/learning-content'; + +export function isValidHttpUrl(url: string): boolean { + try { + const parsedUrl = new URL(url, 'http://test.be'); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch (e) { + return false; + } +} + +export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier) { + let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`; + if (learningObjectId.version) { + url += `&version=${learningObjectId.version}`; + } + return url; +} + +export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string { + let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; + if (learningObjectIdentifier.version) { + url += `&version=${learningObjectIdentifier.version}`; + } + return url; +} diff --git a/backend/src/util/translationHelper.ts b/backend/src/util/translationHelper.ts index 650d9843..d0a83b02 100644 --- a/backend/src/util/translationHelper.ts +++ b/backend/src/util/translationHelper.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; -import { FALLBACK_LANG } from '../../config.js'; +import { FALLBACK_LANG } from '../config.js'; import { getLogger, Logger } from '../logging/initalize.js'; const logger: Logger = getLogger(); @@ -12,15 +12,8 @@ export function loadTranslations(language: string): T { const yamlFile = fs.readFileSync(filePath, 'utf8'); return yaml.load(yamlFile) as T; } catch (error) { - logger.warn( - `Cannot load translation for ${language}, fallen back to dutch`, - error - ); - const fallbackPath = path.join( - process.cwd(), - '_i18n', - `${FALLBACK_LANG}.yml` - ); + logger.warn(`Cannot load translation for ${language}, fallen back to dutch`, error); + const fallbackPath = path.join(process.cwd(), '_i18n', `${FALLBACK_LANG}.yml`); return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T; } } diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts new file mode 100644 index 00000000..c26fb5ba --- /dev/null +++ b/backend/tests/data/assignments/assignments.test.ts @@ -0,0 +1,42 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('AssignmentRepository', () => { + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should return the requested assignment', async () => { + const class_ = await classRepository.findById('id02'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + + expect(assignment).toBeTruthy(); + expect(assignment!.title).toBe('tool'); + }); + + it('should return all assignments for a class', async () => { + const class_ = await classRepository.findById('id02'); + const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); + + expect(assignments).toBeTruthy(); + expect(assignments).toHaveLength(1); + expect(assignments[0].title).toBe('tool'); + }); + + it('should not find removed assignment', async () => { + const class_ = await classRepository.findById('id01'); + await assignmentRepository.deleteByClassAndId(class_!, 3); + + const assignment = await assignmentRepository.findByClassAndId(class_!, 3); + + expect(assignment).toBeNull(); + }); +}); diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts new file mode 100644 index 00000000..96684d68 --- /dev/null +++ b/backend/tests/data/assignments/groups.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { GroupRepository } from '../../../src/data/assignments/group-repository'; +import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('GroupRepository', () => { + let groupRepository: GroupRepository; + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + groupRepository = getGroupRepository(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should return the requested group', async () => { + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + + expect(group).toBeTruthy(); + }); + + it('should return all groups for assignment', async () => { + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + + const groups = await groupRepository.findAllGroupsForAssignment(assignment!); + + expect(groups).toBeTruthy(); + expect(groups).toHaveLength(3); + }); + + it('should not find removed group', async () => { + const class_ = await classRepository.findById('id02'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + + await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1); + + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + + expect(group).toBeNull(); + }); +}); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts new file mode 100644 index 00000000..8712a710 --- /dev/null +++ b/backend/tests/data/assignments/submissions.test.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { SubmissionRepository } from '../../../src/data/assignments/submission-repository'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getStudentRepository, + getSubmissionRepository, +} from '../../../src/data/repositories'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { GroupRepository } from '../../../src/data/assignments/group-repository'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('SubmissionRepository', () => { + let submissionRepository: SubmissionRepository; + let studentRepository: StudentRepository; + let groupRepository: GroupRepository; + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + submissionRepository = getSubmissionRepository(); + studentRepository = getStudentRepository(); + groupRepository = getGroupRepository(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should find the requested submission', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, '1'); + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + expect(submission).toBeTruthy(); + expect(submission?.content).toBe('sub1'); + }); + + it('should find the most recent submission for a student', async () => { + const id = new LearningObjectIdentifier('id02', Language.English, '1'); + const student = await studentRepository.findByUsername('Noordkaap'); + const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, student!); + + expect(submission).toBeTruthy(); + expect(submission?.submissionTime.getDate()).toBe(25); + }); + + it('should find the most recent submission for a group', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, '1'); + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); + + expect(submission).toBeTruthy(); + expect(submission?.submissionTime.getDate()).toBe(25); + }); + + it('should not find a deleted submission', async () => { + const id = new LearningObjectIdentifier('id01', Language.English, '1'); + await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + expect(submission).toBeNull(); + }); +}); diff --git a/backend/tests/data/classes/class-join-request.test.ts b/backend/tests/data/classes/class-join-request.test.ts new file mode 100644 index 00000000..f0aa2f62 --- /dev/null +++ b/backend/tests/data/classes/class-join-request.test.ts @@ -0,0 +1,47 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join-request-repository'; +import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { Student } from '../../../src/entities/users/student.entity'; + +describe('ClassJoinRequestRepository', () => { + let classJoinRequestRepository: ClassJoinRequestRepository; + let studentRepository: StudentRepository; + let cassRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + classJoinRequestRepository = getClassJoinRequestRepository(); + studentRepository = getStudentRepository(); + cassRepository = getClassRepository(); + }); + + it('should list all requests from student to join classes', async () => { + const student = await studentRepository.findByUsername('PinkFloyd'); + const requests = await classJoinRequestRepository.findAllRequestsBy(student!); + + expect(requests).toBeTruthy(); + expect(requests).toHaveLength(2); + }); + + it('should list all requests to a single class', async () => { + const class_ = await cassRepository.findById('id02'); + const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); + + expect(requests).toBeTruthy(); + expect(requests).toHaveLength(2); + }); + + it('should not find a removed request', async () => { + const student = await studentRepository.findByUsername('SmashingPumpkins'); + const class_ = await cassRepository.findById('id03'); + await classJoinRequestRepository.deleteBy(student!, class_!); + + const request = await classJoinRequestRepository.findAllRequestsBy(student!); + + expect(request).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/classes/classes.test.ts b/backend/tests/data/classes/classes.test.ts new file mode 100644 index 00000000..22306ba6 --- /dev/null +++ b/backend/tests/data/classes/classes.test.ts @@ -0,0 +1,34 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { setupTestApp } from '../../setup-tests'; +import { getClassRepository } from '../../../src/data/repositories'; + +describe('ClassRepository', () => { + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + classRepository = getClassRepository(); + }); + + it('should return nothing because id does not exist', async () => { + const classVar = await classRepository.findById('test_id'); + + expect(classVar).toBeNull(); + }); + + it('should return requested class', async () => { + const classVar = await classRepository.findById('id01'); + + expect(classVar).toBeTruthy(); + expect(classVar?.displayName).toBe('class01'); + }); + + it('class should be gone after deletion', async () => { + await classRepository.deleteById('id04'); + + const classVar = await classRepository.findById('id04'); + + expect(classVar).toBeNull(); + }); +}); diff --git a/backend/tests/data/classes/teacher-invitation.test.ts b/backend/tests/data/classes/teacher-invitation.test.ts new file mode 100644 index 00000000..dd03634a --- /dev/null +++ b/backend/tests/data/classes/teacher-invitation.test.ts @@ -0,0 +1,54 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { getClassRepository, getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('ClassRepository', () => { + let teacherInvitationRepository: TeacherInvitationRepository; + let teacherRepository: TeacherRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + teacherInvitationRepository = getTeacherInvitationRepository(); + teacherRepository = getTeacherRepository(); + classRepository = getClassRepository(); + }); + + it('should return all invitations from a teacher', async () => { + const teacher = await teacherRepository.findByUsername('LimpBizkit'); + const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should return all invitations for a teacher', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should return all invitations for a class', async () => { + const class_ = await classRepository.findById('id02'); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should not find a removed invitation', async () => { + const class_ = await classRepository.findById('id01'); + const sender = await teacherRepository.findByUsername('FooFighters'); + const receiver = await teacherRepository.findByUsername('LimpBizkit'); + await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); + + const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!); + + expect(invitation).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/content/attachment-repository.test.ts b/backend/tests/data/content/attachment-repository.test.ts new file mode 100644 index 00000000..85c1f1c5 --- /dev/null +++ b/backend/tests/data/content/attachment-repository.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { Attachment } from '../../../src/entities/content/attachment.entity.js'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; + +const NEWER_TEST_SUFFIX = 'nEweR'; + +function createTestLearningObjects(learningObjectRepo: LearningObjectRepository): { older: LearningObject; newer: LearningObject } { + const olderExample = example.createLearningObject(); + learningObjectRepo.save(olderExample); + + const newerExample = example.createLearningObject(); + newerExample.title = 'Newer example'; + newerExample.version = 100; + + return { + older: olderExample, + newer: newerExample, + }; +} + +describe('AttachmentRepository', () => { + let attachmentRepo: AttachmentRepository; + let exampleLearningObjects: { older: LearningObject; newer: LearningObject }; + let attachmentsOlderLearningObject: Attachment[]; + + beforeAll(async () => { + await setupTestApp(); + attachmentRepo = getAttachmentRepository(); + exampleLearningObjects = createTestLearningObjects(getLearningObjectRepository()); + }); + + it('can add attachments to learning objects without throwing an error', () => { + attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older)); + + for (const attachment of attachmentsOlderLearningObject) { + attachmentRepo.save(attachment); + } + }); + + let attachmentOnlyNewer: Attachment; + it('allows us to add attachments with the same name to a different learning object without throwing an error', () => { + attachmentOnlyNewer = Object.values(example.createAttachment)[0](exampleLearningObjects.newer); + attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX); + + attachmentRepo.save(attachmentOnlyNewer); + }); + + let olderLearningObjectId: LearningObjectIdentifier; + it('returns the correct attachment when queried by learningObjectId and attachment name', async () => { + olderLearningObjectId = { + hruid: exampleLearningObjects.older.hruid, + language: exampleLearningObjects.older.language, + version: exampleLearningObjects.older.version, + }; + + const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name); + expect(result).toBe(attachmentsOlderLearningObject[0]); + }); + + it('returns null when queried by learningObjectId and non-existing attachment name', async () => { + const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, 'non-existing name'); + expect(result).toBe(null); + }); + + it('returns the newer version of the attachment when only queried by hruid, language and attachment name (but not version)', async () => { + const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName( + exampleLearningObjects.older.hruid, + exampleLearningObjects.older.language, + attachmentOnlyNewer.name + ); + expect(result).toBe(attachmentOnlyNewer); + }); +}); diff --git a/backend/tests/data/content/attachments.test.ts b/backend/tests/data/content/attachments.test.ts new file mode 100644 index 00000000..a8bea88a --- /dev/null +++ b/backend/tests/data/content/attachments.test.ts @@ -0,0 +1,31 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; +import { Language } from '../../../src/entities/content/language.js'; + +describe('AttachmentRepository', () => { + let attachmentRepository: AttachmentRepository; + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + attachmentRepository = getAttachmentRepository(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should return the requested attachment', async () => { + const id = new LearningObjectIdentifier('id02', Language.English, '1'); + const learningObject = await learningObjectRepository.findByIdentifier(id); + + const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName( + learningObject!, + Language.English, + 'attachment01' + ); + + expect(attachment).toBeTruthy(); + }); +}); diff --git a/backend/tests/data/content/learning-object-repository.test.ts b/backend/tests/data/content/learning-object-repository.test.ts new file mode 100644 index 00000000..12e14452 --- /dev/null +++ b/backend/tests/data/content/learning-object-repository.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, it, expect } from 'vitest'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { getLearningObjectRepository } from '../../../src/data/repositories.js'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; + +describe('LearningObjectRepository', () => { + let learningObjectRepository: LearningObjectRepository; + + let exampleLearningObject: LearningObject; + + beforeAll(async () => { + await setupTestApp(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should be able to add a learning object to it without an error', async () => { + exampleLearningObject = example.createLearningObject(); + await learningObjectRepository.insert(exampleLearningObject); + }); + + it('should return the learning object when queried by id', async () => { + const result = await learningObjectRepository.findByIdentifier({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + version: exampleLearningObject.version, + }); + expect(result).toBeInstanceOf(LearningObject); + expectToBeCorrectEntity( + { + name: 'actual', + entity: result!, + }, + { + name: 'expected', + entity: exampleLearningObject, + } + ); + }); + + it('should return null when non-existing version is queried', async () => { + const result = await learningObjectRepository.findByIdentifier({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + version: 100, + }); + expect(result).toBe(null); + }); + + let newerExample: LearningObject; + + it('should allow a learning object with the same id except a different version to be added', async () => { + newerExample = example.createLearningObject(); + newerExample.version = 10; + newerExample.title += ' (nieuw)'; + await learningObjectRepository.save(newerExample); + }); + + it('should return the newest version of the learning object when queried by only hruid and language', async () => { + const result = await learningObjectRepository.findLatestByHruidAndLanguage(newerExample.hruid, newerExample.language); + expect(result).toBeInstanceOf(LearningObject); + expect(result?.version).toBe(10); + expect(result?.title).toContain('(nieuw)'); + }); + + it('should return null when queried by non-existing hruid or language', async () => { + const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', exampleLearningObject.language); + expect(result).toBe(null); + }); +}); diff --git a/backend/tests/data/content/learning-objects.test.ts b/backend/tests/data/content/learning-objects.test.ts new file mode 100644 index 00000000..51f9c98e --- /dev/null +++ b/backend/tests/data/content/learning-objects.test.ts @@ -0,0 +1,32 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; +import { getLearningObjectRepository } from '../../../src/data/repositories'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; + +describe('LearningObjectRepository', () => { + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + learningObjectRepository = getLearningObjectRepository(); + }); + + const id01 = new LearningObjectIdentifier('id01', Language.English, '1'); + const id02 = new LearningObjectIdentifier('test_id', Language.English, '1'); + + it('should return the learning object that matches identifier 1', async () => { + const learningObject = await learningObjectRepository.findByIdentifier(id01); + + expect(learningObject).toBeTruthy(); + expect(learningObject?.title).toBe('Undertow'); + expect(learningObject?.description).toBe('debute'); + }); + + it('should return nothing because the identifier does not exist in the database', async () => { + const learningObject = await learningObjectRepository.findByIdentifier(id02); + + expect(learningObject).toBeNull(); + }); +}); diff --git a/backend/tests/data/content/learning-path-repository.test.ts b/backend/tests/data/content/learning-path-repository.test.ts new file mode 100644 index 00000000..8dbff3c1 --- /dev/null +++ b/backend/tests/data/content/learning-path-repository.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getLearningPathRepository } from '../../../src/data/repositories.js'; +import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.js'; +import example from '../../test-assets/learning-paths/pn-werking-example.js'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; +import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; +import { Language } from '../../../src/entities/content/language.js'; + +function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(1); + expectToBeCorrectEntity({ entity: result[0]! }, { entity: expected }); +} + +function expectToHaveFoundNothing(result: LearningPath[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(0); +} + +describe('LearningPathRepository', () => { + let learningPathRepo: LearningPathRepository; + + beforeAll(async () => { + await setupTestApp(); + learningPathRepo = getLearningPathRepository(); + }); + + let examplePath: LearningPath; + + it('should be able to add a learning path without throwing an error', async () => { + examplePath = example.createLearningPath(); + await learningPathRepo.insert(examplePath); + }); + + it('should return the added path when it is queried by hruid and language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage(examplePath.hruid, examplePath.language); + expect(result).toBeInstanceOf(LearningPath); + expectToBeCorrectEntity({ entity: result! }, { entity: examplePath }); + }); + + it('should return null to a query on a non-existing hruid or language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage('not_existing_hruid', examplePath.language); + expect(result).toBe(null); + }); + + it('should return the learning path when we search for a search term occurring in its title', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(4, 9), examplePath.language); + expectToHaveFoundPrecisely(examplePath, result); + }); + + it('should return the learning path when we search for a search term occurring in its description', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(8, 15), examplePath.language); + expectToHaveFoundPrecisely(examplePath, result); + }); + + it('should return null when we search for something not occurring in its title or description', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage('something not occurring in the path', examplePath.language); + expectToHaveFoundNothing(result); + }); + + it('should return null when we search for something occurring in its title, but another language', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(1, 3), Language.Kalaallisut); + expectToHaveFoundNothing(result); + }); +}); diff --git a/backend/tests/data/content/learning-paths.test.ts b/backend/tests/data/content/learning-paths.test.ts new file mode 100644 index 00000000..01fd20e5 --- /dev/null +++ b/backend/tests/data/content/learning-paths.test.ts @@ -0,0 +1,28 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getLearningPathRepository } from '../../../src/data/repositories'; +import { LearningPathRepository } from '../../../src/data/content/learning-path-repository'; +import { setupTestApp } from '../../setup-tests'; +import { Language } from '../../../src/entities/content/language'; + +describe('LearningPathRepository', () => { + let learningPathRepository: LearningPathRepository; + + beforeAll(async () => { + await setupTestApp(); + learningPathRepository = getLearningPathRepository(); + }); + + it('should return nothing because no match for hruid and language', async () => { + const learningPath = await learningPathRepository.findByHruidAndLanguage('test_id', Language.Dutch); + + expect(learningPath).toBeNull(); + }); + + it('should return requested learning path', async () => { + const learningPath = await learningPathRepository.findByHruidAndLanguage('id01', Language.English); + + expect(learningPath).toBeTruthy(); + expect(learningPath?.title).toBe('repertoire Tool'); + expect(learningPath?.description).toBe('all about Tool'); + }); +}); diff --git a/backend/tests/data/questions/answers.test.ts b/backend/tests/data/questions/answers.test.ts new file mode 100644 index 00000000..f15fed6a --- /dev/null +++ b/backend/tests/data/questions/answers.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { AnswerRepository } from '../../../src/data/questions/answer-repository'; +import { getAnswerRepository, getQuestionRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { QuestionRepository } from '../../../src/data/questions/question-repository'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; + +describe('AnswerRepository', () => { + let answerRepository: AnswerRepository; + let questionRepository: QuestionRepository; + let teacherRepository: TeacherRepository; + + beforeAll(async () => { + await setupTestApp(); + answerRepository = getAnswerRepository(); + questionRepository = getQuestionRepository(); + teacherRepository = getTeacherRepository(); + }); + + it('should find all answers to a question', async () => { + const id = new LearningObjectIdentifier('id05', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + const question = questions.filter((it) => it.sequenceNumber == 2)[0]; + + const answers = await answerRepository.findAllAnswersToQuestion(question); + + expect(answers).toBeTruthy(); + expect(answers).toHaveLength(2); + expect(answers[0].content).toBeOneOf(['answer', 'answer2']); + expect(answers[1].content).toBeOneOf(['answer', 'answer2']); + }); + + it('should create an answer to a question', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + const id = new LearningObjectIdentifier('id05', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + const question = questions[0]; + + await answerRepository.createAnswer({ + toQuestion: question, + author: teacher!, + content: 'created answer', + }); + + const answers = await answerRepository.findAllAnswersToQuestion(question); + + expect(answers).toBeTruthy(); + expect(answers).toHaveLength(1); + expect(answers[0].content).toBe('created answer'); + }); + + it('should not find a removed answer', async () => { + const id = new LearningObjectIdentifier('id04', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + await answerRepository.removeAnswerByQuestionAndSequenceNumber(questions[0], 1); + + const emptyList = await answerRepository.findAllAnswersToQuestion(questions[0]); + + expect(emptyList).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts new file mode 100644 index 00000000..1a1cb034 --- /dev/null +++ b/backend/tests/data/questions/questions.test.ts @@ -0,0 +1,52 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { QuestionRepository } from '../../../src/data/questions/question-repository'; +import { getLearningObjectRepository, getQuestionRepository, getStudentRepository } from '../../../src/data/repositories'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; + +describe('QuestionRepository', () => { + let questionRepository: QuestionRepository; + let studentRepository: StudentRepository; + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + questionRepository = getQuestionRepository(); + studentRepository = getStudentRepository(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should return all questions part of the given learning object', async () => { + const id = new LearningObjectIdentifier('id05', Language.English, '1'); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(questions).toBeTruthy(); + expect(questions).toHaveLength(2); + }); + + it('should create new question', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, '1'); + const student = await studentRepository.findByUsername('Noordkaap'); + await questionRepository.createQuestion({ + loId: id, + author: student!, + content: 'question?', + }); + const question = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(question).toBeTruthy(); + expect(question).toHaveLength(1); + }); + + it('should not find removed question', async () => { + const id = new LearningObjectIdentifier('id04', Language.English, '1'); + await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1); + + const question = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(question).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/users.test.ts b/backend/tests/data/users.test.ts deleted file mode 100644 index 887748a2..00000000 --- a/backend/tests/data/users.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { setupTestApp } from '../setup-tests.js'; -import { Student } from '../../src/entities/users/student.entity.js'; -import { describe, it, expect, beforeAll } from 'vitest'; -import { StudentRepository } from '../../src/data/users/student-repository.js'; -import { getStudentRepository } from '../../src/data/repositories.js'; - -const username = 'teststudent'; -const firstName = 'John'; -const lastName = 'Doe'; -describe('StudentRepository', () => { - let studentRepository: StudentRepository; - - beforeAll(async () => { - await setupTestApp(); - studentRepository = getStudentRepository(); - }); - - it('should return the queried student after he was added', async () => { - await studentRepository.insert( - new Student(username, firstName, lastName) - ); - - const retrievedStudent = - await studentRepository.findByUsername(username); - expect(retrievedStudent).toBeTruthy(); - expect(retrievedStudent?.firstName).toBe(firstName); - expect(retrievedStudent?.lastName).toBe(lastName); - }); - - it('should no longer return the queried student after he was removed again', async () => { - await studentRepository.deleteByUsername(username); - - const retrievedStudent = - await studentRepository.findByUsername(username); - expect(retrievedStudent).toBeNull(); - }); -}); diff --git a/backend/tests/data/users/students.test.ts b/backend/tests/data/users/students.test.ts new file mode 100644 index 00000000..78800e1f --- /dev/null +++ b/backend/tests/data/users/students.test.ts @@ -0,0 +1,47 @@ +import { setupTestApp } from '../../setup-tests.js'; +import { Student } from '../../../src/entities/users/student.entity.js'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { StudentRepository } from '../../../src/data/users/student-repository.js'; +import { getStudentRepository } from '../../../src/data/repositories.js'; + +const username = 'teststudent'; +const firstName = 'John'; +const lastName = 'Doe'; +describe('StudentRepository', () => { + let studentRepository: StudentRepository; + + beforeAll(async () => { + await setupTestApp(); + studentRepository = getStudentRepository(); + }); + + it('should not return a student because username does not exist', async () => { + const student = await studentRepository.findByUsername('test'); + + expect(student).toBeNull(); + }); + + it('should return student from the datbase', async () => { + const student = await studentRepository.findByUsername('Noordkaap'); + + expect(student).toBeTruthy(); + expect(student?.firstName).toBe('Stijn'); + expect(student?.lastName).toBe('Meuris'); + }); + + it('should return the queried student after he was added', async () => { + await studentRepository.insert(new Student(username, firstName, lastName)); + + const retrievedStudent = await studentRepository.findByUsername(username); + expect(retrievedStudent).toBeTruthy(); + expect(retrievedStudent?.firstName).toBe(firstName); + expect(retrievedStudent?.lastName).toBe(lastName); + }); + + it('should no longer return the queried student after he was removed again', async () => { + await studentRepository.deleteByUsername(username); + + const retrievedStudent = await studentRepository.findByUsername(username); + expect(retrievedStudent).toBeNull(); + }); +}); diff --git a/backend/tests/data/users/teachers.test.ts b/backend/tests/data/users/teachers.test.ts new file mode 100644 index 00000000..0bd014a6 --- /dev/null +++ b/backend/tests/data/users/teachers.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; +import { setupTestApp } from '../../setup-tests'; +import { getTeacherRepository } from '../../../src/data/repositories'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; + +const username = 'testteacher'; +const firstName = 'John'; +const lastName = 'Doe'; +describe('TeacherRepository', () => { + let teacherRepository: TeacherRepository; + + beforeAll(async () => { + await setupTestApp(); + teacherRepository = getTeacherRepository(); + }); + + it('should not return a teacher because username does not exist', async () => { + const teacher = await teacherRepository.findByUsername('test'); + + expect(teacher).toBeNull(); + }); + + it('should return teacher from the datbase', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + + expect(teacher).toBeTruthy(); + expect(teacher?.firstName).toBe('Dave'); + expect(teacher?.lastName).toBe('Grohl'); + }); + + it('should return the queried teacher after he was added', async () => { + await teacherRepository.insert(new Teacher(username, firstName, lastName)); + + const retrievedTeacher = await teacherRepository.findByUsername(username); + expect(retrievedTeacher).toBeTruthy(); + expect(retrievedTeacher?.firstName).toBe(firstName); + expect(retrievedTeacher?.lastName).toBe(lastName); + }); + + it('should no longer return the queried teacher after he was removed again', async () => { + await teacherRepository.deleteByUsername('ZesdeMetaal'); + + const retrievedTeacher = await teacherRepository.findByUsername('ZesdeMetaal'); + expect(retrievedTeacher).toBeNull(); + }); +}); diff --git a/backend/tests/service/learning-objects.test.ts b/backend/tests/service/learning-objects.test.ts new file mode 100644 index 00000000..130c237e --- /dev/null +++ b/backend/tests/service/learning-objects.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LearningObjectMetadata, LearningPath } from '../../src/interfaces/learningPath'; +import { fetchWithLogging } from '../../src/util/apiHelper'; +import { getLearningObjectById, getLearningObjectsFromPath } from '../../src/services/learningObjects'; +import { fetchLearningPaths } from '../../src/services/learningPaths'; + +// Mock API functions +vi.mock('../../src/util/apiHelper', () => ({ + fetchWithLogging: vi.fn(), +})); + +vi.mock('../../src/services/learningPaths', () => ({ + fetchLearningPaths: vi.fn(), +})); + +describe('getLearningObjectById', () => { + const hruid = 'test-object'; + const language = 'en'; + const mockMetadata: LearningObjectMetadata = { + hruid, + _id: '123', + uuid: 'uuid-123', + version: 1, + title: 'Test Object', + language, + difficulty: 5, + estimated_time: 120, + available: true, + teacher_exclusive: false, + educational_goals: [{ source: 'source', id: 'id' }], + keywords: ['robotics'], + description: 'A test object', + target_ages: [10, 12], + content_type: 'markdown', + content_location: '', + }; + + it('✅ Should return a filtered learning object when API provides data', async () => { + vi.mocked(fetchWithLogging).mockResolvedValueOnce(mockMetadata); + + const result = await getLearningObjectById(hruid, language); + + expect(result).toEqual({ + key: hruid, + _id: '123', + uuid: 'uuid-123', + version: 1, + title: 'Test Object', + htmlUrl: expect.stringContaining('/learningObject/getRaw?hruid=test-object&language=en'), + language, + difficulty: 5, + estimatedTime: 120, + available: true, + teacherExclusive: false, + educationalGoals: [{ source: 'source', id: 'id' }], + keywords: ['robotics'], + description: 'A test object', + targetAges: [10, 12], + contentType: 'markdown', + contentLocation: '', + }); + }); + + it('⚠️ Should return null if API returns no metadata', async () => { + vi.mocked(fetchWithLogging).mockResolvedValueOnce(null); + const result = await getLearningObjectById(hruid, language); + expect(result).toBeNull(); + }); +}); + +describe('getLearningObjectsFromPath', () => { + const hruid = 'test-path'; + const language = 'en'; + + it('✅ Should not give error or warning', async () => { + const mockPathResponse: LearningPath[] = [ + { + _id: 'path-1', + hruid, + language, + title: 'Test Path', + description: '', + num_nodes: 1, + num_nodes_left: 0, + nodes: [], + keywords: '', + target_ages: [], + min_age: 10, + max_age: 12, + __order: 1, + }, + ]; + + vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ + success: true, + source: 'Test Source', + data: mockPathResponse, + }); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); + + it('⚠️ Should give a warning', async () => { + vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ success: false, source: 'Test Source', data: [] }); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); + + it('❌ Should give an error', async () => { + vi.mocked(fetchLearningPaths).mockRejectedValueOnce(new Error('API Error')); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); +}); diff --git a/backend/tests/service/learning-paths.test.ts b/backend/tests/service/learning-paths.test.ts new file mode 100644 index 00000000..c002dbac --- /dev/null +++ b/backend/tests/service/learning-paths.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fetchLearningPaths, searchLearningPaths } from '../../src/services/learningPaths'; +import { fetchWithLogging } from '../../src/util/apiHelper'; +import { LearningPathResponse } from '../../src/interfaces/learningPath'; + +// Mock the fetchWithLogging module using vi +vi.mock('../../src/util/apiHelper', () => ({ + fetchWithLogging: vi.fn(), +})); + +describe('fetchLearningPaths', () => { + // Mock data and response + const mockHruids = ['pn_werking', 'art1']; + const language = 'en'; + const source = 'Test Source'; + const mockResponse = [{ title: 'Test Path', hruids: mockHruids }]; + + it('✅ Should return a successful response when HRUIDs are provided', async () => { + // Mock the function to return mockResponse + vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); + + const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockResponse); + expect(result.source).toBe(source); + }); + + it('⚠️ Should return an error when no HRUIDs are provided', async () => { + vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); + + const result: LearningPathResponse = await fetchLearningPaths([], language, source); + + expect(result.success).toBe(false); + expect(result.data).toBeNull(); + expect(result.message).toBe(`No HRUIDs provided for ${source}.`); + }); + + it('⚠️ Should return a failure response when no learning paths are found', async () => { + // Mock fetchWithLogging to return an empty array + vi.mocked(fetchWithLogging).mockResolvedValue([]); + + const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); + + expect(result.success).toBe(false); + expect(result.data).toEqual([]); + expect(result.message).toBe(`No learning paths found for ${source}.`); + }); +}); + +describe('searchLearningPaths', () => { + const query = + 'https://dwengo.org/backend/api/learningPath/getPathsFromIdList?pathIdList=%7B%22hruids%22:%5B%22pn_werking%22,%22un_artificiele_intelligentie%22%5D%7D&language=nl'; + const language = 'nl'; + + it('✅ Should return search results when API responds with data', async () => { + const mockResults = [ + { + _id: '67b4488c9dadb305c4104618', + language: 'nl', + hruid: 'pn_werking', + title: 'Werken met notebooks', + description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?', + num_nodes: 0, + num_nodes_left: 0, + nodes: [], + keywords: 'Python KIKS Wiskunde STEM AI', + target_ages: [14, 15, 16, 17, 18], + min_age: 14, + max_age: 18, + __order: 0, + }, + ]; + + // Mock fetchWithLogging to return search results + vi.mocked(fetchWithLogging).mockResolvedValue(mockResults); + + const result = await searchLearningPaths(query, language); + + expect(result).toEqual(mockResults); + }); + + it('⚠️ Should return an empty array when API returns no results', async () => { + vi.mocked(fetchWithLogging).mockResolvedValue([]); + + const result = await searchLearningPaths(query, language); + + expect(result).toEqual([]); + }); +}); diff --git a/backend/tests/services/learning-objects/database-learning-object-provider.test.ts b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts new file mode 100644 index 00000000..692a72de --- /dev/null +++ b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts @@ -0,0 +1,108 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider'; +import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations'; +import { FilteredLearningObject } from '../../../src/interfaces/learning-content'; +import { Language } from '../../../src/entities/content/language'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan'; + +describe('DatabaseLearningObjectProvider', () => { + let exampleLearningObject: LearningObject; + let exampleLearningPath: LearningPath; + + beforeAll(async () => { + await setupTestApp(); + const exampleData = await initExampleData(); + exampleLearningObject = exampleData.learningObject; + exampleLearningPath = exampleData.learningPath; + }); + describe('getLearningObjectById', () => { + it('should return the learning object when it is queried by its id', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById(exampleLearningObject); + expect(result).toBeTruthy(); + expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); + }); + + it('should return the learning object when it is queried by only hruid and language (but not version)', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + }); + expect(result).toBeTruthy(); + expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); + }); + + it('should return null when queried with an id that does not exist', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + describe('getLearningObjectHTML', () => { + it('should return the correct rendering of the learning object', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject); + expect(result).toEqual(example.getHTMLRendering()); + }); + it('should return null for a non-existing learning object', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectHTML({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + describe('getLearningObjectIdsFromPath', () => { + it('should return all learning object IDs from a path', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath); + expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + }); + it('should throw an error if queried with a path identifier for which there is no learning path', async () => { + await expect( + (async () => { + await databaseLearningObjectProvider.getLearningObjectIdsFromPath({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + })() + ).rejects.toThrowError(); + }); + }); + describe('getLearningObjectsFromPath', () => { + it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPath); + expect(result.length).toBe(exampleLearningPath.nodes.length); + expect(new Set(result.map((it) => it.key))).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + + expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT); + }); + it('should throw an error if queried with a path identifier for which there is no learning path', async () => { + await expect( + (async () => { + await databaseLearningObjectProvider.getLearningObjectsFromPath({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + })() + ).rejects.toThrowError(); + }); + }); +}); diff --git a/backend/tests/services/learning-objects/learning-object-service.test.ts b/backend/tests/services/learning-objects/learning-object-service.test.ts new file mode 100644 index 00000000..d80262df --- /dev/null +++ b/backend/tests/services/learning-objects/learning-object-service.test.ts @@ -0,0 +1,126 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningObjectService from '../../../src/services/learning-objects/learning-object-service'; +import { LearningObjectIdentifier, LearningPathIdentifier } from '../../../src/interfaces/learning-content'; +import { Language } from '../../../src/entities/content/language'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; + +const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks'; +const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifier = { + hruid: 'pn_werkingnotebooks', + language: Language.Dutch, + version: 3, +}; + +const DWENGO_TEST_LEARNING_PATH_ID: LearningPathIdentifier = { + hruid: 'pn_werking', + language: Language.Dutch, +}; +const DWENGO_TEST_LEARNING_PATH_HRUIDS = new Set(['pn_werkingnotebooks', 'pn_werkingnotebooks2', 'pn_werkingnotebooks3']); + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +describe('LearningObjectService', () => { + let exampleLearningObject: LearningObject; + let exampleLearningPath: LearningPath; + + beforeAll(async () => { + await setupTestApp(); + const exampleData = await initExampleData(); + exampleLearningObject = exampleData.learningObject; + exampleLearningPath = exampleData.learningPath; + }); + + describe('getLearningObjectById', () => { + it('returns the learning object from the Dwengo API if it does not have the user content prefix', async () => { + const result = await learningObjectService.getLearningObjectById(DWENGO_TEST_LEARNING_OBJECT_ID); + expect(result).not.toBeNull(); + expect(result?.title).toBe(EXPECTED_DWENGO_LEARNING_OBJECT_TITLE); + }); + it('returns the learning object from the database if it does have the user content prefix', async () => { + const result = await learningObjectService.getLearningObjectById(exampleLearningObject); + expect(result).not.toBeNull(); + expect(result?.title).toBe(exampleLearningObject.title); + }); + it('returns null if the hruid does not have the user content prefix and does not exist in the Dwengo repo', async () => { + const result = await learningObjectService.getLearningObjectById({ + hruid: 'non-existing', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + + describe('getLearningObjectHTML', () => { + it('returns the expected HTML when queried with the identifier of a learning object saved in the database', async () => { + const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject); + expect(result).not.toBeNull(); + expect(result).toEqual(learningObjectExample.getHTMLRendering()); + }); + it( + 'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' + + 'not start with the user content prefix', + async () => { + const result = await learningObjectService.getLearningObjectHTML(DWENGO_TEST_LEARNING_OBJECT_ID); + expect(result).not.toBeNull(); + + const responseFromDwengoApi = await fetch( + getEnvVar(EnvVars.LearningContentRepoApiBaseUrl) + + `/learningObject/getRaw?hruid=${DWENGO_TEST_LEARNING_OBJECT_ID.hruid}&language=${DWENGO_TEST_LEARNING_OBJECT_ID.language}&version=${DWENGO_TEST_LEARNING_OBJECT_ID.version}` + ); + const responseHtml = await responseFromDwengoApi.text(); + expect(result).toEqual(responseHtml); + } + ); + it('returns null when queried with a non-existing identifier', async () => { + const result = await learningObjectService.getLearningObjectHTML({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + + describe('getLearningObjectsFromPath', () => { + it('returns all learning objects when a learning path in the database is queried', async () => { + const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath); + expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + }); + it('also returns all learning objects when a learning path from the Dwengo API is queried', async () => { + const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID); + expect(new Set(result.map((it) => it.key))).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); + }); + it('returns an empty list when queried with a non-existing learning path id', async () => { + const result = await learningObjectService.getLearningObjectsFromPath({ hruid: 'non_existing', language: Language.Dutch }); + expect(result).toEqual([]); + }); + }); + + describe('getLearningObjectIdsFromPath', () => { + it('returns all learning objects when a learning path in the database is queried', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath); + expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + }); + it('also returns all learning object hruids when a learning path from the Dwengo API is queried', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID); + expect(new Set(result)).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); + }); + it('returns an empty list when queried with a non-existing learning path id', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath({ hruid: 'non_existing', language: Language.Dutch }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/backend/tests/services/learning-objects/processing/processing-service.test.ts b/backend/tests/services/learning-objects/processing/processing-service.test.ts new file mode 100644 index 00000000..27714317 --- /dev/null +++ b/backend/tests/services/learning-objects/processing/processing-service.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import mdExample from '../../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import multipleChoiceExample from '../../../test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example'; +import essayExample from '../../../test-assets/learning-objects/test-essay/test-essay-example'; +import processingService from '../../../../src/services/learning-objects/processing/processing-service'; + +describe('ProcessingService', () => { + it('renders a markdown learning object correctly', async () => { + const markdownLearningObject = mdExample.createLearningObject(); + const result = await processingService.render(markdownLearningObject); + expect(result).toEqual(mdExample.getHTMLRendering()); + }); + + it('renders a multiple choice question correctly', async () => { + const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject(); + const result = await processingService.render(multipleChoiceLearningObject); + expect(result).toEqual(multipleChoiceExample.getHTMLRendering()); + }); + + it('renders an essay question correctly', async () => { + const essayLearningObject = essayExample.createLearningObject(); + const result = await processingService.render(essayLearningObject); + expect(result).toEqual(essayExample.getHTMLRendering()); + }); +}); diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts new file mode 100644 index 00000000..7c7ecdae --- /dev/null +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -0,0 +1,220 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; +import { + getLearningObjectRepository, + getLearningPathRepository, + getStudentRepository, + getSubmissionRepository, +} from '../../../src/data/repositories.js'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example.js'; +import databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider.js'; +import { expectToBeCorrectLearningPath } from '../../test-utils/expectations.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import learningObjectService from '../../../src/services/learning-objects/learning-object-service.js'; +import { Language } from '../../../src/entities/content/language.js'; +import { + ConditionTestLearningPathAndLearningObjects, + createConditionTestLearningPathAndLearningObjects, +} from '../../test-assets/learning-paths/test-conditions-example.js'; +import { Student } from '../../../src/entities/users/student.entity.js'; +import { LearningObjectNode, LearningPathResponse } from '../../../src/interfaces/learning-content.js'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +async function initPersonalizationTestData(): Promise<{ + learningContent: ConditionTestLearningPathAndLearningObjects; + studentA: Student; + studentB: Student; +}> { + const studentRepo = getStudentRepository(); + const submissionRepo = getSubmissionRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObjectRepo = getLearningObjectRepository(); + const learningContent = createConditionTestLearningPathAndLearningObjects(); + await learningObjectRepo.save(learningContent.branchingObject); + await learningObjectRepo.save(learningContent.finalObject); + await learningObjectRepo.save(learningContent.extraExerciseObject); + await learningPathRepo.save(learningContent.learningPath); + + console.log(await getSubmissionRepository().findAll({})); + + const studentA = studentRepo.create({ + username: 'student_a', + firstName: 'Aron', + lastName: 'Student', + }); + await studentRepo.save(studentA); + const submissionA = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 0, + submitter: studentA, + submissionTime: new Date(), + content: '[0]', + }); + await submissionRepo.save(submissionA); + + const studentB = studentRepo.create({ + username: 'student_b', + firstName: 'Bill', + lastName: 'Student', + }); + await studentRepo.save(studentB); + const submissionB = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 1, + submitter: studentB, + submissionTime: new Date(), + content: '[1]', + }); + await submissionRepo.save(submissionB); + + return { + learningContent: learningContent, + studentA: studentA, + studentB: studentB, + }; +} + +function expectBranchingObjectNode( + result: LearningPathResponse, + persTestData: { + learningContent: ConditionTestLearningPathAndLearningObjects; + studentA: Student; + studentB: Student; + } +): LearningObjectNode { + const branchingObjectMatches = result.data![0].nodes.filter( + (it) => it.learningobject_hruid === persTestData.learningContent.branchingObject.hruid + ); + expect(branchingObjectMatches.length).toBe(1); + return branchingObjectMatches[0]; +} + +describe('DatabaseLearningPathProvider', () => { + let learningObjectRepo: LearningObjectRepository; + let example: { learningObject: LearningObject; learningPath: LearningPath }; + let persTestData: { learningContent: ConditionTestLearningPathAndLearningObjects; studentA: Student; studentB: Student }; + + beforeAll(async () => { + await setupTestApp(); + example = await initExampleData(); + persTestData = await initPersonalizationTestData(); + learningObjectRepo = getLearningObjectRepository(); + }); + + describe('fetchLearningPaths', () => { + it('returns the learning path correctly', async () => { + const result = await databaseLearningPathProvider.fetchLearningPaths( + [example.learningPath.hruid], + example.learningPath.language, + 'the source' + ); + expect(result.success).toBe(true); + expect(result.data?.length).toBe(1); + + const learningObjectsOnPath = ( + await Promise.all( + example.learningPath.nodes.map((node) => + learningObjectService.getLearningObjectById({ + hruid: node.learningObjectHruid, + version: node.version, + language: node.language, + }) + ) + ) + ).filter((it) => it !== null); + + expectToBeCorrectLearningPath(result.data![0], example.learningPath, learningObjectsOnPath); + }); + it('returns the correct personalized learning path', async () => { + // For student A: + let result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + { type: 'student', student: persTestData.studentA } + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should be exactly one branching object + let branchingObject = expectBranchingObjectNode(result, persTestData); + + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(0); // StudentA picked the first option, therefore, there should be no direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( + 1 + ); // There should however be a path to the extra exercise object. + + // For student B: + result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + { type: 'student', student: persTestData.studentB } + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should still be exactly one branching object + branchingObject = expectBranchingObjectNode(result, persTestData); + + // However, now the student picks the other option. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(1); // StudentB picked the second option, therefore, there should be a direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( + 0 + ); // There should not be a path anymore to the extra exercise object. + }); + it('returns a non-successful response if a non-existing learning path is queried', async () => { + const result = await databaseLearningPathProvider.fetchLearningPaths( + [example.learningPath.hruid], + Language.Abkhazian, // Wrong language + 'the source' + ); + + expect(result.success).toBe(false); + }); + }); + + describe('searchLearningPaths', () => { + it('returns the correct learning path when queried with a substring of its title', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + example.learningPath.title.substring(2, 6), + example.learningPath.language + ); + expect(result.length).toBe(1); + expect(result[0].title).toBe(example.learningPath.title); + expect(result[0].description).toBe(example.learningPath.description); + }); + it('returns the correct learning path when queried with a substring of the description', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + example.learningPath.description.substring(5, 12), + example.learningPath.language + ); + expect(result.length).toBe(1); + expect(result[0].title).toBe(example.learningPath.title); + expect(result[0].description).toBe(example.learningPath.description); + }); + it('returns an empty result when queried with a text which is not a substring of the title or the description of a learning path', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + 'substring which does not occur in the title or the description of a learning object', + example.learningPath.language + ); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/backend/tests/services/learning-path/learning-path-service.test.ts b/backend/tests/services/learning-path/learning-path-service.test.ts new file mode 100644 index 00000000..2a8906df --- /dev/null +++ b/backend/tests/services/learning-path/learning-path-service.test.ts @@ -0,0 +1,81 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; +import { Language } from '../../../src/entities/content/language'; +import learningPathService from '../../../src/services/learning-paths/learning-path-service'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +const TEST_DWENGO_LEARNING_PATH_HRUID = 'pn_werking'; +const TEST_DWENGO_LEARNING_PATH_TITLE = 'Werken met notebooks'; +const TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY = 'Microscopie'; +const TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES = 'su$m8f9usf89ud { + let example: { learningObject: LearningObject; learningPath: LearningPath }; + beforeAll(async () => { + await setupTestApp(); + example = await initExampleData(); + }); + describe('fetchLearningPaths', () => { + it('should return learning paths both from the database and from the Dwengo API', async () => { + const result = await learningPathService.fetchLearningPaths( + [example.learningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID], + example.learningPath.language, + 'the source' + ); + expect(result.success).toBeTruthy(); + expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID)[0].title).toEqual(TEST_DWENGO_LEARNING_PATH_TITLE); + expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid)[0].title).toEqual(example.learningPath.title); + }); + it('should include both the learning objects from the Dwengo API and learning objects from the database in its response', async () => { + const result = await learningPathService.fetchLearningPaths([example.learningPath.hruid], example.learningPath.language, 'the source'); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // Should include all the nodes, even those pointing to foreign learning objects. + expect([...result.data![0].nodes.map((it) => it.learningobject_hruid)].sort()).toEqual( + example.learningPath.nodes.map((it) => it.learningObjectHruid).sort() + ); + }); + }); + describe('searchLearningPath', () => { + it('should include both the learning paths from the Dwengo API and those from the database in its response', async () => { + // This matches the learning object in the database, but definitely also some learning objects in the Dwengo API. + const result = await learningPathService.searchLearningPaths(example.learningPath.title.substring(2, 3), example.learningPath.language); + + // Should find the one from the database + expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(1); + + // But should not only find that one. + expect(result.length).not.toBeLessThan(2); + }); + it('should still return results from the Dwengo API even though there are no matches in the database', async () => { + const result = await learningPathService.searchLearningPaths(TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY, Language.Dutch); + + // Should find something... + expect(result.length).not.toBe(0); + + // But not the example learning path. + expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(0); + }); + it('should return an empty list if neither the Dwengo API nor the database contains matches', async () => { + const result = await learningPathService.searchLearningPaths(TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES, Language.Dutch); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts index 6c9d23f6..9502bcb8 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -1,7 +1,59 @@ -import { initORM } from '../src/orm.js'; +import { forkEntityManager, initORM } from '../src/orm.js'; import dotenv from 'dotenv'; +import { makeTestStudents } from './test_assets/users/students.testdata.js'; +import { makeTestTeachers } from './test_assets/users/teachers.testdata.js'; +import { makeTestLearningObjects } from './test_assets/content/learning-objects.testdata.js'; +import { makeTestLearningPaths } from './test_assets/content/learning-paths.testdata.js'; +import { makeTestClasses } from './test_assets/classes/classes.testdata.js'; +import { makeTestAssignemnts } from './test_assets/assignments/assignments.testdata.js'; +import { makeTestGroups } from './test_assets/assignments/groups.testdata.js'; +import { makeTestTeacherInvitations } from './test_assets/classes/teacher-invitations.testdata.js'; +import { makeTestClassJoinRequests } from './test_assets/classes/class-join-requests.testdata.js'; +import { makeTestAttachments } from './test_assets/content/attachments.testdata.js'; +import { makeTestQuestions } from './test_assets/questions/questions.testdata.js'; +import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; +import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; export async function setupTestApp() { dotenv.config({ path: '.env.test' }); await initORM(true); + + const em = forkEntityManager(); + + const students = makeTestStudents(em); + const teachers = makeTestTeachers(em); + const learningObjects = makeTestLearningObjects(em); + const learningPaths = makeTestLearningPaths(em); + const classes = makeTestClasses(em, students, teachers); + const assignments = makeTestAssignemnts(em, classes); + const groups = makeTestGroups(em, students, assignments); + + assignments[0].groups = groups.slice(0, 3); + assignments[1].groups = groups.slice(3, 4); + + const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); + const classJoinRequests = makeTestClassJoinRequests(em, students, classes); + const attachments = makeTestAttachments(em, learningObjects); + + learningObjects[1].attachments = attachments; + + const questions = makeTestQuestions(em, students); + const answers = makeTestAnswers(em, teachers, questions); + const submissions = makeTestSubmissions(em, students, groups); + + await em.persistAndFlush([ + ...students, + ...teachers, + ...learningObjects, + ...learningPaths, + ...classes, + ...assignments, + ...groups, + ...teacherInvitations, + ...classJoinRequests, + ...attachments, + ...questions, + ...answers, + ...submissions, + ]); } diff --git a/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts new file mode 100644 index 00000000..9bd0b4c3 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts @@ -0,0 +1,10 @@ +import { LearningObjectExample } from './learning-object-example'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; + +export function createExampleLearningObjectWithAttachments(example: LearningObjectExample): LearningObject { + const learningObject = example.createLearningObject(); + for (const creationFn of Object.values(example.createAttachment)) { + learningObject.attachments.push(creationFn(learningObject)); + } + return learningObject; +} diff --git a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts new file mode 100644 index 00000000..2f2e78ad --- /dev/null +++ b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts @@ -0,0 +1,32 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { Language } from '../../../../src/entities/content/language'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; + +/** + * Create a dummy learning object to be used in tests where multiple learning objects are needed (for example for use + * on a path), but where the precise contents of the learning object are not important. + */ +export function dummyLearningObject(hruid: string, language: Language, title: string): LearningObjectExample { + return { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = getEnvVar(EnvVars.UserContentPrefix) + hruid; + learningObject.language = language; + learningObject.version = 1; + learningObject.title = title; + learningObject.description = 'Just a dummy learning object for testing purposes'; + learningObject.contentType = DwengoContentType.TEXT_PLAIN; + learningObject.content = Buffer.from('Dummy content'); + learningObject.returnValue = { + callbackUrl: `/learningObject/${hruid}/submissions`, + callbackSchema: '[]', + }; + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/dummy/rendering.txt').toString(), + }; +} diff --git a/backend/tests/test-assets/learning-objects/dummy/rendering.txt b/backend/tests/test-assets/learning-objects/dummy/rendering.txt new file mode 100644 index 00000000..f3ae8006 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/dummy/rendering.txt @@ -0,0 +1 @@ +Dummy content diff --git a/backend/tests/test-assets/learning-objects/learning-object-example.d.ts b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts new file mode 100644 index 00000000..1d4009f8 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts @@ -0,0 +1,8 @@ +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { Attachment } from '../../../src/entities/content/attachment.entity'; + +type LearningObjectExample = { + createLearningObject: () => LearningObject; + createAttachment: { [key: string]: (owner: LearningObject) => Attachment }; + getHTMLRendering: () => string; +}; diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png new file mode 100644 index 00000000..34920e91 Binary files /dev/null and b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png differ diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md new file mode 100644 index 00000000..f469e46c --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md @@ -0,0 +1,26 @@ +# Werken met notebooks + +Het lesmateriaal van 'Python in wiskunde en STEM' wordt aangeboden in de vorm van interactieve **_notebooks_**. Notebooks zijn _digitale documenten_ die zowel uitvoerbare code bevatten als tekst, afbeeldingen, video, hyperlinks ... + +_Nieuwe begrippen_ worden aangebracht via tekstuele uitleg, video en afbeeldingen. + +Er zijn uitgewerkte _voorbeelden_ met daarnaast ook kleine en grote _opdrachten_. In deze opdrachten zal je aangereikte code kunnen uitvoeren, maar ook zelf code opstellen. + +De code die in de notebooks gebruikt wordt, is Python versie 3. We kozen voor Python omdat dit een heel toegankelijke programmeertaal is, die vaak ook intuïtief is. +Python is bovendien bezig aan een opmars en wordt gebruikt door bedrijven, zoals Google, NASA, Netflix, Uber, AstraZeneca, Barco, Instagram en YouTube. + +We kozen voor notebooks omdat daar enkele belangrijke voordelen aan verbonden zijn: leerkrachten moeten geen geavanceerde installaties doen om de notebooks te gebruiken, leerkrachten kunnen verschillende soorten van lesinhouden aanbieden via één platform, de notebooks zijn interactief, leerlingen bouwen de oplossing van een probleem stap voor stap op in de notebook waardoor dat proces zichtbaar is voor de leerkracht ([Jeroen Van der Hooft, 2023](https://libstore.ugent.be/fulltxt/RUG01/003/151/437/RUG01-003151437_2023_0001_AC.pdf)). + +--- + +Klik je op onderstaande knop 'Open notebooks', dan word je doorgestuurd naar een andere website waar jouw persoonlijke notebooks ingeladen worden. (Dit kan even duren.) + +Links op het scherm vind je er twee bestanden met extensie _.ipynb_. +Dit zijn de twee notebooks waarin je resp. een overzicht krijgt van de opbouw en mogelijkheden en hoe je er mee aan de slag kan. Dubbelklik op de bestandsnaam om een notebook te openen. + +Je ziet er ook een map _images_ met de afbeeldingen die in de notebooks getoond worden. + +In deze eerste twee notebooks leer je hoe de notebooks zijn opgevat en hoe je ermee aan de slag kan. +Na het doorlopen van beide notebooks heb je een goed idee van hoe onze Python notebooks zijn opgevat. + +[![](Knop.png 'Knop')](https://kiks.ilabt.imec.be/hub/tmplogin?id=0101 'Notebooks Werking') diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png new file mode 100644 index 00000000..b03fcd08 Binary files /dev/null and b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png differ diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts new file mode 100644 index 00000000..600a4305 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts @@ -0,0 +1,72 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EducationalGoal, LearningObject, ReturnValue } from '../../../../src/entities/content/learning-object.entity'; +import { Attachment } from '../../../../src/entities/content/attachment.entity'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; + +const ASSETS_PREFIX = 'learning-objects/pn-werkingnotebooks/'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}pn_werkingnotebooks`; + learningObject.version = 3; + learningObject.language = Language.Dutch; + learningObject.title = 'Werken met notebooks'; + learningObject.description = 'Leren werken met notebooks'; + learningObject.keywords = ['Python', 'KIKS', 'Wiskunde', 'STEM', 'AI']; + + const educationalGoal1 = new EducationalGoal(); + educationalGoal1.source = 'Source'; + educationalGoal1.id = 'id'; + + const educationalGoal2 = new EducationalGoal(); + educationalGoal2.source = 'Source2'; + educationalGoal2.id = 'id2'; + + learningObject.educationalGoals = [educationalGoal1, educationalGoal2]; + learningObject.admins = []; + learningObject.contentType = DwengoContentType.TEXT_MARKDOWN; + learningObject.teacherExclusive = false; + learningObject.skosConcepts = [ + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-vaktaal', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-digitale-media-en-toepassingen', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-computers-en-systemen', + ]; + learningObject.copyright = 'dwengo'; + learningObject.license = 'dwengo'; + learningObject.estimatedTime = 10; + + const returnValue = new ReturnValue(); + returnValue.callbackUrl = 'callback_url_example'; + returnValue.callbackSchema = '{"att": "test", "att2": "test2"}'; + + learningObject.returnValue = returnValue; + learningObject.available = true; + learningObject.content = loadTestAsset(`${ASSETS_PREFIX}/content.md`); + + return learningObject; + }, + createAttachment: { + dwengoLogo: (learningObject) => { + const att = new Attachment(); + att.learningObject = learningObject; + att.name = 'dwengo.png'; + att.mimeType = 'image/png'; + att.content = loadTestAsset(`${ASSETS_PREFIX}/dwengo.png`); + return att; + }, + knop: (learningObject) => { + const att = new Attachment(); + att.learningObject = learningObject; + att.name = 'Knop.png'; + att.mimeType = 'image/png'; + att.content = loadTestAsset(`${ASSETS_PREFIX}/Knop.png`); + return att; + }, + }, + getHTMLRendering: () => loadTestAsset(`${ASSETS_PREFIX}/rendering.txt`).toString(), +}; +export default example; diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt new file mode 100644 index 00000000..af596243 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt @@ -0,0 +1,20 @@ +

+ + + + Werken met notebooks +

+

Het lesmateriaal van 'Python in wiskunde en STEM' wordt aangeboden in de vorm van interactieve notebooks. Notebooks zijn digitale documenten die zowel uitvoerbare code bevatten als tekst, afbeeldingen, video, hyperlinks ...

+

Nieuwe begrippen worden aangebracht via tekstuele uitleg, video en afbeeldingen.

+

Er zijn uitgewerkte voorbeelden met daarnaast ook kleine en grote opdrachten. In deze opdrachten zal je aangereikte code kunnen uitvoeren, maar ook zelf code opstellen.

+

De code die in de notebooks gebruikt wordt, is Python versie 3. We kozen voor Python omdat dit een heel toegankelijke programmeertaal is, die vaak ook intuC/tief is. +Python is bovendien bezig aan een opmars en wordt gebruikt door bedrijven, zoals Google, NASA, Netflix, Uber, AstraZeneca, Barco, Instagram en YouTube.

+

We kozen voor notebooks omdat daar enkele belangrijke voordelen aan verbonden zijn: leerkrachten moeten geen geavanceerde installaties doen om de notebooks te gebruiken, leerkrachten kunnen verschillende soorten van lesinhouden aanbieden via C)C)n platform, de notebooks zijn interactief, leerlingen bouwen de oplossing van een probleem stap voor stap op in de notebook waardoor dat proces zichtbaar is voor de leerkracht (Jeroen Van der Hooft, 2023).

+
+

Klik je op onderstaande knop 'Open notebooks', dan word je doorgestuurd naar een andere website waar jouw persoonlijke notebooks ingeladen worden. (Dit kan even duren.)

+

Links op het scherm vind je er twee bestanden met extensie .ipynb. +Dit zijn de twee notebooks waarin je resp. een overzicht krijgt van de opbouw en mogelijkheden en hoe je er mee aan de slag kan. Dubbelklik op de bestandsnaam om een notebook te openen.

+

Je ziet er ook een map images met de afbeeldingen die in de notebooks getoond worden.

+

In deze eerste twee notebooks leer je hoe de notebooks zijn opgevat en hoe je ermee aan de slag kan. +Na het doorlopen van beide notebooks heb je een goed idee van hoe onze Python notebooks zijn opgevat.

+

diff --git a/backend/tests/test-assets/learning-objects/test-essay/content.txt b/backend/tests/test-assets/learning-objects/test-essay/content.txt new file mode 100644 index 00000000..dd6b5c77 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/content.txt @@ -0,0 +1,2 @@ +::MC basic:: +How are you? {} diff --git a/backend/tests/test-assets/learning-objects/test-essay/rendering.txt b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt new file mode 100644 index 00000000..adb072a0 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt @@ -0,0 +1,7 @@ +
+
+

MC basic

+

How are you?

+ +
+
diff --git a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts new file mode 100644 index 00000000..d57c7a33 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts @@ -0,0 +1,28 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_essay`; + learningObject.language = Language.English; + learningObject.version = 1; + learningObject.title = 'Essay question for testing'; + learningObject.description = 'This essay question was only created for testing purposes.'; + learningObject.contentType = DwengoContentType.GIFT; + learningObject.returnValue = { + callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, + callbackSchema: '["antwoord vraag 1"]', + }; + learningObject.content = loadTestAsset('learning-objects/test-essay/content.txt'); + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/test-essay/rendering.txt').toString(), +}; + +export default example; diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt b/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt new file mode 100644 index 00000000..7dd5527d --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt @@ -0,0 +1,5 @@ +::MC basic:: +Are you following along well with the class? { + ~No, it's very difficult to follow along. + =Yes, no problem! +} diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt b/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt new file mode 100644 index 00000000..c1829f24 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt @@ -0,0 +1,14 @@ +
+
+

MC basic

+

Are you following along well with the class?

+
+ + +
+
+ + +
+
+
diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts new file mode 100644 index 00000000..a634878a --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts @@ -0,0 +1,28 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_multiple_choice`; + learningObject.language = Language.English; + learningObject.version = 1; + learningObject.title = 'Multiple choice question for testing'; + learningObject.description = 'This multiple choice question was only created for testing purposes.'; + learningObject.contentType = DwengoContentType.GIFT; + learningObject.returnValue = { + callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, + callbackSchema: '["antwoord vraag 1"]', + }; + learningObject.content = loadTestAsset('learning-objects/test-multiple-choice/content.txt'); + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/test-multiple-choice/rendering.txt').toString(), +}; + +export default example; diff --git a/backend/tests/test-assets/learning-paths/learning-path-example.d.ts b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts new file mode 100644 index 00000000..9df3ba48 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts @@ -0,0 +1,3 @@ +type LearningPathExample = { + createLearningPath: () => LearningPath; +}; diff --git a/backend/tests/test-assets/learning-paths/learning-path-utils.ts b/backend/tests/test-assets/learning-paths/learning-path-utils.ts new file mode 100644 index 00000000..c567de66 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/learning-path-utils.ts @@ -0,0 +1,31 @@ +import { Language } from '../../../src/entities/content/language'; +import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; + +export function createLearningPathTransition(node: LearningPathNode, transitionNumber: number, condition: string | null, to: LearningPathNode) { + const trans = new LearningPathTransition(); + trans.node = node; + trans.transitionNumber = transitionNumber; + trans.condition = condition || 'true'; + trans.next = to; + return trans; +} + +export function createLearningPathNode( + learningPath: LearningPath, + nodeNumber: number, + learningObjectHruid: string, + version: number, + language: Language, + startNode: boolean +) { + const node = new LearningPathNode(); + node.learningPath = learningPath; + node.nodeNumber = nodeNumber; + node.learningObjectHruid = learningObjectHruid; + node.version = version; + node.language = language; + node.startNode = startNode; + return node; +} diff --git a/backend/tests/test-assets/learning-paths/pn-werking-example.ts b/backend/tests/test-assets/learning-paths/pn-werking-example.ts new file mode 100644 index 00000000..810b4da5 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/pn-werking-example.ts @@ -0,0 +1,30 @@ +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; +import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; + +function createNodes(learningPath: LearningPath): LearningPathNode[] { + const nodes = [ + createLearningPathNode(learningPath, 0, 'u_pn_werkingnotebooks', 3, Language.Dutch, true), + createLearningPathNode(learningPath, 1, 'pn_werkingnotebooks2', 3, Language.Dutch, false), + createLearningPathNode(learningPath, 2, 'pn_werkingnotebooks3', 3, Language.Dutch, false), + ]; + nodes[0].transitions.push(createLearningPathTransition(nodes[0], 0, 'true', nodes[1])); + nodes[1].transitions.push(createLearningPathTransition(nodes[1], 0, 'true', nodes[2])); + return nodes; +} + +const example: LearningPathExample = { + createLearningPath: () => { + const path = new LearningPath(); + path.language = Language.Dutch; + path.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}pn_werking`; + path.title = 'Werken met notebooks'; + path.description = 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?'; + path.nodes = createNodes(path); + return path; + }, +}; + +export default example; diff --git a/backend/tests/test-assets/learning-paths/test-conditions-example.ts b/backend/tests/test-assets/learning-paths/test-conditions-example.ts new file mode 100644 index 00000000..07857235 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/test-conditions-example.ts @@ -0,0 +1,84 @@ +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import testMultipleChoiceExample from '../learning-objects/test-multiple-choice/test-multiple-choice-example'; +import { dummyLearningObject } from '../learning-objects/dummy/dummy-learning-object-example'; +import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; + +export type ConditionTestLearningPathAndLearningObjects = { + branchingObject: LearningObject; + extraExerciseObject: LearningObject; + finalObject: LearningObject; + learningPath: LearningPath; +}; + +export function createConditionTestLearningPathAndLearningObjects() { + const learningPath = new LearningPath(); + learningPath.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_conditions`; + learningPath.language = Language.English; + learningPath.title = 'Example learning path with conditional transitions'; + learningPath.description = 'This learning path was made for the purpose of testing conditional transitions'; + + const branchingLearningObject = testMultipleChoiceExample.createLearningObject(); + const extraExerciseLearningObject = dummyLearningObject( + 'test_extra_exercise', + Language.English, + 'Extra exercise (for students with difficulties)' + ).createLearningObject(); + const finalLearningObject = dummyLearningObject( + 'test_final_learning_object', + Language.English, + 'Final exercise (for everyone)' + ).createLearningObject(); + + const branchingNode = createLearningPathNode( + learningPath, + 0, + branchingLearningObject.hruid, + branchingLearningObject.version, + branchingLearningObject.language, + true + ); + const extraExerciseNode = createLearningPathNode( + learningPath, + 1, + extraExerciseLearningObject.hruid, + extraExerciseLearningObject.version, + extraExerciseLearningObject.language, + false + ); + const finalNode = createLearningPathNode( + learningPath, + 2, + finalLearningObject.hruid, + finalLearningObject.version, + finalLearningObject.language, + false + ); + + const transitionToExtraExercise = createLearningPathTransition( + branchingNode, + 0, + '$[?(@[0] == 0)]', // The answer to the first question was the first one, which says that it is difficult for the student to follow along. + extraExerciseNode + ); + const directTransitionToFinal = createLearningPathTransition(branchingNode, 1, '$[?(@[0] == 1)]', finalNode); + const transitionExtraExerciseToFinal = createLearningPathTransition(extraExerciseNode, 0, 'true', finalNode); + + branchingNode.transitions = [transitionToExtraExercise, directTransitionToFinal]; + extraExerciseNode.transitions = [transitionExtraExerciseToFinal]; + + learningPath.nodes = [branchingNode, extraExerciseNode, finalNode]; + + return { + branchingObject: branchingLearningObject, + finalObject: finalLearningObject, + extraExerciseObject: extraExerciseLearningObject, + learningPath: learningPath, + }; +} + +const example: LearningPathExample = { + createLearningPath: () => createConditionTestLearningPathAndLearningObjects().learningPath, +}; diff --git a/backend/tests/test-utils/expectations.ts b/backend/tests/test-utils/expectations.ts new file mode 100644 index 00000000..0fe63811 --- /dev/null +++ b/backend/tests/test-utils/expectations.ts @@ -0,0 +1,150 @@ +import { AssertionError } from 'node:assert'; +import { LearningObject } from '../../src/entities/content/learning-object.entity'; +import { FilteredLearningObject, LearningPath } from '../../src/interfaces/learning-content'; +import { LearningPath as LearningPathEntity } from '../../src/entities/content/learning-path.entity'; +import { expect } from 'vitest'; + +// Ignored properties because they belang for example to the class, not to the entity itself. +const IGNORE_PROPERTIES = ['parent']; + +/** + * Checks if the actual entity from the database conforms to the entity that was added previously. + * @param actual The actual entity retrieved from the database + * @param expected The (previously added) entity we would expect to retrieve + */ +export function expectToBeCorrectEntity(actual: { entity: T; name?: string }, expected: { entity: T; name?: string }): void { + if (!actual.name) { + actual.name = 'actual'; + } + if (!expected.name) { + expected.name = 'expected'; + } + for (const property in expected.entity) { + if ( + property! in IGNORE_PROPERTIES && + expected.entity[property] !== undefined && // If we don't expect a certain value for a property, we assume it can be filled in by the database however it wants. + typeof expected.entity[property] !== 'function' // Functions obviously are not persisted via the database + ) { + if (!actual.entity.hasOwnProperty(property)) { + throw new AssertionError({ + message: `${expected.name} has defined property ${property}, but ${actual.name} is missing it.`, + }); + } + if (typeof expected.entity[property] === 'boolean') { + // Sometimes, booleans get represented by numbers 0 and 1 in the objects actual from the database. + if (Boolean(expected.entity[property]) !== Boolean(actual.entity[property])) { + throw new AssertionError({ + message: `${property} was ${expected.entity[property]} in ${expected.name}, + but ${actual.entity[property]} (${Boolean(expected.entity[property])}) in ${actual.name}`, + }); + } + } else if (typeof expected.entity[property] !== typeof actual.entity[property]) { + throw new AssertionError({ + message: `${property} has type ${typeof expected.entity[property]} in ${expected.name}, but type ${typeof actual.entity[property]} in ${actual.name}.`, + }); + } else if (typeof expected.entity[property] === 'object') { + expectToBeCorrectEntity( + { + name: actual.name + '.' + property, + entity: actual.entity[property] as object, + }, + { + name: expected.name + '.' + property, + entity: expected.entity[property] as object, + } + ); + } else { + if (expected.entity[property] !== actual.entity[property]) { + throw new AssertionError({ + message: `${property} was ${expected.entity[property]} in ${expected.name}, but ${actual.entity[property]} in ${actual.name}`, + }); + } + } + } + } +} + +/** + * Checks that filtered is the correct representation of original as FilteredLearningObject. + * @param filtered the representation as FilteredLearningObject + * @param original the original entity added to the database + */ +export function expectToBeCorrectFilteredLearningObject(filtered: FilteredLearningObject, original: LearningObject) { + expect(filtered.uuid).toEqual(original.uuid); + expect(filtered.version).toEqual(original.version); + expect(filtered.language).toEqual(original.language); + expect(filtered.keywords).toEqual(original.keywords); + expect(filtered.key).toEqual(original.hruid); + expect(filtered.targetAges).toEqual(original.targetAges); + expect(filtered.title).toEqual(original.title); + expect(Boolean(filtered.teacherExclusive)).toEqual(Boolean(original.teacherExclusive)); + expect(filtered.skosConcepts).toEqual(original.skosConcepts); + expect(filtered.estimatedTime).toEqual(original.estimatedTime); + expect(filtered.educationalGoals).toEqual(original.educationalGoals); + expect(filtered.difficulty).toEqual(original.difficulty || 1); + expect(filtered.description).toEqual(original.description); + expect(filtered.returnValue?.callback_url).toEqual(original.returnValue.callbackUrl); + expect(filtered.returnValue?.callback_schema).toEqual(JSON.parse(original.returnValue.callbackSchema)); + expect(filtered.contentType).toEqual(original.contentType); + expect(filtered.contentLocation || null).toEqual(original.contentLocation || null); + expect(filtered.htmlUrl).toContain(`/${original.hruid}/html`); + expect(filtered.htmlUrl).toContain(`language=${original.language}`); + expect(filtered.htmlUrl).toContain(`version=${original.version}`); +} + +/** + * Check that a learning path returned by a LearningPathRetriever, the LearningPathService or an API endpoint + * is a correct representation of the given learning path entity. + * + * @param learningPath The learning path returned by the retriever, service or endpoint + * @param expectedEntity The expected entity + * @param learningObjectsOnPath The learning objects on LearningPath. Necessary since some information in + * the learning path returned from the API endpoint + */ +export function expectToBeCorrectLearningPath( + learningPath: LearningPath, + expectedEntity: LearningPathEntity, + learningObjectsOnPath: FilteredLearningObject[] +) { + expect(learningPath.hruid).toEqual(expectedEntity.hruid); + expect(learningPath.language).toEqual(expectedEntity.language); + expect(learningPath.description).toEqual(expectedEntity.description); + expect(learningPath.title).toEqual(expectedEntity.title); + + const keywords = new Set(learningObjectsOnPath.flatMap((it) => it.keywords || [])); + expect(new Set(learningPath.keywords.split(' '))).toEqual(keywords); + + const targetAges = new Set(learningObjectsOnPath.flatMap((it) => it.targetAges || [])); + expect(new Set(learningPath.target_ages)).toEqual(targetAges); + expect(learningPath.min_age).toEqual(Math.min(...targetAges)); + expect(learningPath.max_age).toEqual(Math.max(...targetAges)); + + expect(learningPath.num_nodes).toEqual(expectedEntity.nodes.length); + expect(learningPath.image || null).toEqual(expectedEntity.image); + + const expectedLearningPathNodes = new Map( + expectedEntity.nodes.map((node) => [ + { learningObjectHruid: node.learningObjectHruid, language: node.language, version: node.version }, + { startNode: node.startNode, transitions: node.transitions }, + ]) + ); + + for (const node of learningPath.nodes) { + const nodeKey = { + learningObjectHruid: node.learningobject_hruid, + language: node.language, + version: node.version, + }; + expect(expectedLearningPathNodes.keys()).toContainEqual(nodeKey); + const expectedNode = [...expectedLearningPathNodes.entries()].filter( + ([key, _]) => key.learningObjectHruid === nodeKey.learningObjectHruid && key.language === node.language && key.version === node.version + )[0][1]; + expect(node.start_node).toEqual(expectedNode?.startNode); + + expect(new Set(node.transitions.map((it) => it.next.hruid))).toEqual( + new Set(expectedNode.transitions.map((it) => it.next.learningObjectHruid)) + ); + expect(new Set(node.transitions.map((it) => it.next.language))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.language))); + expect(new Set(node.transitions.map((it) => it.next.version))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.version))); + } +} diff --git a/backend/tests/test-utils/load-test-asset.ts b/backend/tests/test-utils/load-test-asset.ts new file mode 100644 index 00000000..35f6cdbf --- /dev/null +++ b/backend/tests/test-utils/load-test-asset.ts @@ -0,0 +1,10 @@ +import fs from 'fs'; +import path from 'node:path'; + +/** + * Load the asset at the given path. + * @param relPath Path of the asset relative to the test-assets folder. + */ +export function loadTestAsset(relPath: string): Buffer { + return fs.readFileSync(path.resolve(__dirname, `../test-assets/${relPath}`)); +} diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts new file mode 100644 index 00000000..7f909de4 --- /dev/null +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -0,0 +1,38 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Language } from '../../../src/entities/content/language'; + +export function makeTestAssignemnts(em: EntityManager>, classes: Array): Array { + const assignment01 = em.create(Assignment, { + within: classes[0], + id: 1, + title: 'dire straits', + description: 'reading', + learningPathHruid: 'id02', + learningPathLanguage: Language.English, + groups: [], + }); + + const assignment02 = em.create(Assignment, { + within: classes[1], + id: 2, + title: 'tool', + description: 'reading', + learningPathHruid: 'id01', + learningPathLanguage: Language.English, + groups: [], + }); + + const assignment03 = em.create(Assignment, { + within: classes[0], + id: 3, + title: 'delete', + description: 'will be deleted', + learningPathHruid: 'id02', + learningPathLanguage: Language.English, + groups: [], + }); + + return [assignment01, assignment02, assignment03]; +} diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts new file mode 100644 index 00000000..0e9ef201 --- /dev/null +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Group } from '../../../src/entities/assignments/group.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestGroups( + em: EntityManager>, + students: Array, + assignments: Array +): Array { + const group01 = em.create(Group, { + assignment: assignments[0], + groupNumber: 1, + members: students.slice(0, 2), + }); + + const group02 = em.create(Group, { + assignment: assignments[0], + groupNumber: 2, + members: students.slice(2, 4), + }); + + const group03 = em.create(Group, { + assignment: assignments[0], + groupNumber: 3, + members: students.slice(4, 6), + }); + + const group04 = em.create(Group, { + assignment: assignments[1], + groupNumber: 4, + members: students.slice(3, 4), + }); + + return [group01, group02, group03, group04]; +} diff --git a/backend/tests/test_assets/assignments/submission.testdata.ts b/backend/tests/test_assets/assignments/submission.testdata.ts new file mode 100644 index 00000000..058af70f --- /dev/null +++ b/backend/tests/test_assets/assignments/submission.testdata.ts @@ -0,0 +1,65 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Submission } from '../../../src/entities/assignments/submission.entity'; +import { Language } from '../../../src/entities/content/language'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Group } from '../../../src/entities/assignments/group.entity'; + +export function makeTestSubmissions( + em: EntityManager>, + students: Array, + groups: Array +): Array { + const submission01 = em.create(Submission, { + learningObjectHruid: 'id03', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 1, + submitter: students[0], + submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[0], + content: 'sub1', + }); + + const submission02 = em.create(Submission, { + learningObjectHruid: 'id03', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 2, + submitter: students[0], + submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[0], + content: '', + }); + + const submission03 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 1, + submitter: students[0], + submissionTime: new Date(2025, 2, 20), + content: '', + }); + + const submission04 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 2, + submitter: students[0], + submissionTime: new Date(2025, 2, 25), + content: '', + }); + + const submission05 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + submissionNumber: 1, + submitter: students[1], + submissionTime: new Date(2025, 2, 20), + content: '', + }); + + return [submission01, submission02, submission03, submission04, submission05]; +} diff --git a/backend/tests/test_assets/classes/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts new file mode 100644 index 00000000..8d9e328f --- /dev/null +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { ClassJoinRequest, ClassJoinRequestStatus } from '../../../src/entities/classes/class-join-request.entity'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; + +export function makeTestClassJoinRequests( + em: EntityManager>, + students: Array, + classes: Array +): Array { + const classJoinRequest01 = em.create(ClassJoinRequest, { + requester: students[4], + class: classes[1], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest02 = em.create(ClassJoinRequest, { + requester: students[2], + class: classes[1], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest03 = em.create(ClassJoinRequest, { + requester: students[4], + class: classes[2], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest04 = em.create(ClassJoinRequest, { + requester: students[3], + class: classes[2], + status: ClassJoinRequestStatus.Open, + }); + + return [classJoinRequest01, classJoinRequest02, classJoinRequest03, classJoinRequest04]; +} diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts new file mode 100644 index 00000000..b3e98bc8 --- /dev/null +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -0,0 +1,48 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; + +export function makeTestClasses(em: EntityManager>, students: Array, teachers: Array): Array { + const studentsClass01 = students.slice(0, 7); + const teacherClass01: Array = teachers.slice(0, 1); + + const class01 = em.create(Class, { + classId: 'id01', + displayName: 'class01', + teachers: teacherClass01, + students: studentsClass01, + }); + + const studentsClass02: Array = students.slice(0, 2).concat(students.slice(3, 4)); + const teacherClass02: Array = teachers.slice(1, 2); + + const class02 = em.create(Class, { + classId: 'id02', + displayName: 'class02', + teachers: teacherClass02, + students: studentsClass02, + }); + + const studentsClass03: Array = students.slice(1, 4); + const teacherClass03: Array = teachers.slice(2, 3); + + const class03 = em.create(Class, { + classId: 'id03', + displayName: 'class03', + teachers: teacherClass03, + students: studentsClass03, + }); + + const studentsClass04: Array = students.slice(0, 2); + const teacherClass04: Array = teachers.slice(2, 3); + + const class04 = em.create(Class, { + classId: 'id04', + displayName: 'class04', + teachers: teacherClass04, + students: studentsClass04, + }); + + return [class01, class02, class03, class04]; +} diff --git a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts new file mode 100644 index 00000000..84eeab01 --- /dev/null +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { TeacherInvitation } from '../../../src/entities/classes/teacher-invitation.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; + +export function makeTestTeacherInvitations( + em: EntityManager>, + teachers: Array, + classes: Array +): Array { + const teacherInvitation01 = em.create(TeacherInvitation, { + sender: teachers[1], + receiver: teachers[0], + class: classes[1], + }); + + const teacherInvitation02 = em.create(TeacherInvitation, { + sender: teachers[1], + receiver: teachers[2], + class: classes[1], + }); + + const teacherInvitation03 = em.create(TeacherInvitation, { + sender: teachers[2], + receiver: teachers[0], + class: classes[2], + }); + + const teacherInvitation04 = em.create(TeacherInvitation, { + sender: teachers[0], + receiver: teachers[1], + class: classes[0], + }); + + return [teacherInvitation01, teacherInvitation02, teacherInvitation03, teacherInvitation04]; +} diff --git a/backend/tests/test_assets/content/attachments.testdata.ts b/backend/tests/test_assets/content/attachments.testdata.ts new file mode 100644 index 00000000..9f690d9c --- /dev/null +++ b/backend/tests/test_assets/content/attachments.testdata.ts @@ -0,0 +1,14 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Attachment } from '../../../src/entities/content/attachment.entity'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; + +export function makeTestAttachments(em: EntityManager>, learningObjects: Array): Array { + const attachment01 = em.create(Attachment, { + learningObject: learningObjects[1], + name: 'attachment01', + mimeType: '', + content: Buffer.from(''), + }); + + return [attachment01]; +} diff --git a/backend/tests/test_assets/content/learning-objects.testdata.ts b/backend/tests/test_assets/content/learning-objects.testdata.ts new file mode 100644 index 00000000..17ed4f01 --- /dev/null +++ b/backend/tests/test_assets/content/learning-objects.testdata.ts @@ -0,0 +1,134 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { LearningObject, ReturnValue } from '../../../src/entities/content/learning-object.entity'; +import { Language } from '../../../src/entities/content/language'; +import { DwengoContentType } from '../../../src/services/learning-objects/processing/content-type'; + +export function makeTestLearningObjects(em: EntityManager>): Array { + const returnValue: ReturnValue = new ReturnValue(); + returnValue.callbackSchema = ''; + returnValue.callbackUrl = ''; + + const learningObject01 = em.create(LearningObject, { + hruid: 'id01', + language: Language.English, + version: 1, + admins: [], + title: 'Undertow', + description: 'debute', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 45, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from("there's a shadow just behind me, shrouding every step i take, making every promise empty pointing every finger at me"), + }); + + const learningObject02 = em.create(LearningObject, { + hruid: 'id02', + language: Language.English, + version: 1, + admins: [], + title: 'Aenema', + description: 'second album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 80, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + "I've been crawling on my belly clearing out what could've been I've been wallowing in my own confused and insecure delusions" + ), + }); + + const learningObject03 = em.create(LearningObject, { + hruid: 'id03', + language: Language.English, + version: 1, + admins: [], + title: 'love over gold', + description: 'third album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'he wrote me a prescription, he said you are depressed, \ + but I am glad you came to see me to get this off your chest, \ + come back and see me later next patient please \ + send in another victim of industrial disease' + ), + }); + + const learningObject04 = em.create(LearningObject, { + hruid: 'id04', + language: Language.English, + version: 1, + admins: [], + title: 'making movies', + description: 'fifth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'I put my hand upon the lever \ + Said let it rock and let it roll \ + I had the one-arm bandit fever \ + There was an arrow through my heart and my soul' + ), + }); + + const learningObject05 = em.create(LearningObject, { + hruid: 'id05', + language: Language.English, + version: 1, + admins: [], + title: 'on every street', + description: 'sixth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'), + }); + + return [learningObject01, learningObject02, learningObject03, learningObject04, learningObject05]; +} diff --git a/backend/tests/test_assets/content/learning-paths.testdata.ts b/backend/tests/test_assets/content/learning-paths.testdata.ts new file mode 100644 index 00000000..d2e65c9e --- /dev/null +++ b/backend/tests/test_assets/content/learning-paths.testdata.ts @@ -0,0 +1,100 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; + +export function makeTestLearningPaths(em: EntityManager>): Array { + const learningPathNode01: LearningPathNode = new LearningPathNode(); + const learningPathNode02: LearningPathNode = new LearningPathNode(); + const learningPathNode03: LearningPathNode = new LearningPathNode(); + const learningPathNode04: LearningPathNode = new LearningPathNode(); + const learningPathNode05: LearningPathNode = new LearningPathNode(); + + const transitions01: LearningPathTransition = new LearningPathTransition(); + const transitions02: LearningPathTransition = new LearningPathTransition(); + const transitions03: LearningPathTransition = new LearningPathTransition(); + const transitions04: LearningPathTransition = new LearningPathTransition(); + const transitions05: LearningPathTransition = new LearningPathTransition(); + + transitions01.condition = 'true'; + transitions01.next = learningPathNode02; + + transitions02.condition = 'true'; + transitions02.next = learningPathNode02; + + transitions03.condition = 'true'; + transitions03.next = learningPathNode04; + + transitions04.condition = 'true'; + transitions04.next = learningPathNode05; + + transitions05.condition = 'true'; + transitions05.next = learningPathNode05; + + learningPathNode01.instruction = ''; + learningPathNode01.language = Language.English; + learningPathNode01.learningObjectHruid = 'id01'; + learningPathNode01.startNode = true; + learningPathNode01.transitions = [transitions01]; + learningPathNode01.version = 1; + + learningPathNode02.instruction = ''; + learningPathNode02.language = Language.English; + learningPathNode02.learningObjectHruid = 'id02'; + learningPathNode02.startNode = false; + learningPathNode02.transitions = [transitions02]; + learningPathNode02.version = 1; + + learningPathNode03.instruction = ''; + learningPathNode03.language = Language.English; + learningPathNode03.learningObjectHruid = 'id03'; + learningPathNode03.startNode = true; + learningPathNode03.transitions = [transitions03]; + learningPathNode03.version = 1; + + learningPathNode04.instruction = ''; + learningPathNode04.language = Language.English; + learningPathNode04.learningObjectHruid = 'id04'; + learningPathNode04.startNode = false; + learningPathNode04.transitions = [transitions04]; + learningPathNode04.version = 1; + + learningPathNode05.instruction = ''; + learningPathNode05.language = Language.English; + learningPathNode05.learningObjectHruid = 'id05'; + learningPathNode05.startNode = false; + learningPathNode05.transitions = [transitions05]; + learningPathNode05.version = 1; + + const nodes01: Array = [ + // LearningPathNode01, + // LearningPathNode02, + ]; + const learningPath01 = em.create(LearningPath, { + hruid: 'id01', + language: Language.English, + admins: [], + title: 'repertoire Tool', + description: 'all about Tool', + image: '', + nodes: nodes01, + }); + + const nodes02: Array = [ + // LearningPathNode03, + // LearningPathNode04, + // LearningPathNode05, + ]; + const learningPath02 = em.create(LearningPath, { + hruid: 'id02', + language: Language.English, + admins: [], + title: 'repertoire Dire Straits', + description: 'all about Dire Straits', + image: '', + nodes: nodes02, + }); + + return [learningPath01, learningPath02]; +} diff --git a/backend/tests/test_assets/questions/answers.testdata.ts b/backend/tests/test_assets/questions/answers.testdata.ts new file mode 100644 index 00000000..20e816da --- /dev/null +++ b/backend/tests/test_assets/questions/answers.testdata.ts @@ -0,0 +1,32 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Answer } from '../../../src/entities/questions/answer.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Question } from '../../../src/entities/questions/question.entity'; + +export function makeTestAnswers(em: EntityManager>, teachers: Array, questions: Array): Array { + const answer01 = em.create(Answer, { + author: teachers[0], + toQuestion: questions[1], + sequenceNumber: 1, + timestamp: new Date(), + content: 'answer', + }); + + const answer02 = em.create(Answer, { + author: teachers[0], + toQuestion: questions[1], + sequenceNumber: 2, + timestamp: new Date(), + content: 'answer2', + }); + + const answer03 = em.create(Answer, { + author: teachers[1], + toQuestion: questions[3], + sequenceNumber: 1, + timestamp: new Date(), + content: 'answer3', + }); + + return [answer01, answer02, answer03]; +} diff --git a/backend/tests/test_assets/questions/questions.testdata.ts b/backend/tests/test_assets/questions/questions.testdata.ts new file mode 100644 index 00000000..23552152 --- /dev/null +++ b/backend/tests/test_assets/questions/questions.testdata.ts @@ -0,0 +1,48 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Question } from '../../../src/entities/questions/question.entity'; +import { Language } from '../../../src/entities/content/language'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestQuestions(em: EntityManager>, students: Array): Array { + const question01 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id05', + sequenceNumber: 1, + author: students[0], + timestamp: new Date(), + content: 'question', + }); + + const question02 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id05', + sequenceNumber: 2, + author: students[2], + timestamp: new Date(), + content: 'question', + }); + + const question03 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id04', + sequenceNumber: 1, + author: students[0], + timestamp: new Date(), + content: 'question', + }); + + const question04 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: '1', + learningObjectHruid: 'id01', + sequenceNumber: 1, + author: students[1], + timestamp: new Date(), + content: 'question', + }); + + return [question01, question02, question03, question04]; +} diff --git a/backend/tests/test_assets/users/students.testdata.ts b/backend/tests/test_assets/users/students.testdata.ts new file mode 100644 index 00000000..61e0b590 --- /dev/null +++ b/backend/tests/test_assets/users/students.testdata.ts @@ -0,0 +1,49 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestStudents(em: EntityManager>): Array { + const student01 = em.create(Student, { + username: 'Noordkaap', + firstName: 'Stijn', + lastName: 'Meuris', + }); + + const student02 = em.create(Student, { + username: 'DireStraits', + firstName: 'Mark', + lastName: 'Knopfler', + }); + + const student03 = em.create(Student, { + username: 'Tool', + firstName: 'Maynard', + lastName: 'Keenan', + }); + + const student04 = em.create(Student, { + username: 'SmashingPumpkins', + firstName: 'Billy', + lastName: 'Corgan', + }); + + const student05 = em.create(Student, { + username: 'PinkFloyd', + firstName: 'David', + lastName: 'Gilmoure', + }); + + const student06 = em.create(Student, { + username: 'TheDoors', + firstName: 'Jim', + lastName: 'Morisson', + }); + + // Do not use for any tests, gets deleted in a unit test + const student07 = em.create(Student, { + username: 'Nirvana', + firstName: 'Kurt', + lastName: 'Cobain', + }); + + return [student01, student02, student03, student04, student05, student06, student07]; +} diff --git a/backend/tests/test_assets/users/teachers.testdata.ts b/backend/tests/test_assets/users/teachers.testdata.ts new file mode 100644 index 00000000..d8985e44 --- /dev/null +++ b/backend/tests/test_assets/users/teachers.testdata.ts @@ -0,0 +1,31 @@ +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; + +export function makeTestTeachers(em: EntityManager>): Array { + const teacher01 = em.create(Teacher, { + username: 'FooFighters', + firstName: 'Dave', + lastName: 'Grohl', + }); + + const teacher02 = em.create(Teacher, { + username: 'LimpBizkit', + firstName: 'Fred', + lastName: 'Durst', + }); + + const teacher03 = em.create(Teacher, { + username: 'Staind', + firstName: 'Aaron', + lastName: 'Lewis', + }); + + // Should not be used, gets deleted in a unit test + const teacher04 = em.create(Teacher, { + username: 'ZesdeMetaal', + firstName: 'Wannes', + lastName: 'Cappelle', + }); + + return [teacher01, teacher02, teacher03, teacher04]; +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 302015fb..461d2018 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: 'node', globals: true, + testTimeout: 10000, }, }); diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 00000000..5c35441e --- /dev/null +++ b/compose.override.yml @@ -0,0 +1,72 @@ +# +# Use this configuration to test the production configuration locally. +# +# This configuration builds the frontend and backend services as Docker images, +# and uses the paths for the services, instead of ports. +# +services: + web: + build: + context: . + dockerfile: frontend/Dockerfile + ports: + - '8080:8080/tcp' + restart: unless-stopped + labels: + - 'traefik.http.routers.web.rule=PathPrefix(`/`)' + - 'traefik.http.services.web.loadbalancer.server.port=8080' + + api: + build: + context: . + dockerfile: backend/Dockerfile + ports: + - '3000:3000/tcp' + restart: unless-stopped + volumes: + - ./backend/.env:/app/.env + depends_on: + - db + - logging + labels: + - 'traefik.http.routers.api.rule=PathPrefix(`/api`)' + - 'traefik.http.services.api.loadbalancer.server.port=3000' + + idp: + # Also see compose.yml + labels: + - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' + - 'traefik.http.services.idp.loadbalancer.server.port=7080' + environment: + PROXY_ADDRESS_FORWARDING: 'true' + KC_HTTP_RELATIVE_PATH: '/idp' + + reverse-proxy: + image: traefik:v3.3 + command: + # Enable web UI + - '--api.insecure=true' + + # Add Docker provider + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=true' + + # Add web entrypoint + - '--entrypoints.web.address=:80/tcp' + ports: + - '9000:8080' + - '80:80/tcp' + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + dashboards: + image: grafana/grafana:latest + ports: + - '9002:3000' + volumes: + - dwengo_grafana_data:/var/lib/grafana + restart: unless-stopped + +volumes: + dwengo_grafana_data: diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 00000000..8825796e --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,112 @@ +# +# This file is used to define the production environment for the project. +# It is used to deploy the project on a server. +# Should not be used for local development. +# +services: + web: + build: + context: . + dockerfile: frontend/Dockerfile + restart: unless-stopped + networks: + - dwengo-1 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.web.rule=PathPrefix(`/`)' + - 'traefik.http.services.web.loadbalancer.server.port=8080' + + api: + build: + context: . + dockerfile: backend/Dockerfile + restart: unless-stopped + volumes: + # TODO Replace with environment keys + - ./backend/.env:/app/.env + depends_on: + - db + - logging + networks: + - dwengo-1 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.api.rule=PathPrefix(`/api`)' + - 'traefik.http.services.api.loadbalancer.server.port=3000' + + db: + # Also see compose.yml + networks: + - dwengo-1 + + idp: + # Also see compose.yml + # TODO Replace with proper production command + command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm'] + networks: + - dwengo-1 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' + - 'traefik.http.services.idp.loadbalancer.server.port=7080' + env_file: + - ./config/idp/.env + environment: + KC_HOSTNAME: 'sel2-1.ugent.be' + PROXY_ADDRESS_FORWARDING: 'true' + KC_PROXY_HEADERS: 'xforwarded' + KC_HTTP_ENABLED: 'true' + KC_HTTP_RELATIVE_PATH: '/idp' + + reverse-proxy: + image: traefik:v3.3 + ports: + - '80:80/tcp' + - '443:443/tcp' + command: + # Add Docker provider + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=false' + + # Add web entrypoint + - '--entrypoints.web.address=:80/tcp' + - '--entrypoints.web.http.redirections.entryPoint.to=websecure' + - '--entrypoints.web.http.redirections.entryPoint.scheme=https' + + # Add websecure entrypoint + - '--entrypoints.websecure.address=:443/tcp' + - '--entrypoints.websecure.http.tls=true' + - '--entrypoints.websecure.http.tls.certResolver=letsencrypt' + - '--entrypoints.websecure.http.tls.domains[0].main=sel2-1.ugent.be' + + # Certificates + - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true' + - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' + - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' + - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - dwengo_letsencrypt:/letsencrypt + networks: + - dwengo-1 + + logging: + # Also see compose.yml + networks: + - dwengo-1 + + dashboards: + image: grafana/grafana:latest + ports: + - '9002:3000' + restart: unless-stopped + volumes: + - dwengo_grafana_data:/var/lib/grafana + +volumes: + dwengo_grafana_data: + dwengo_letsencrypt: + +networks: + dwengo-1: diff --git a/docker-compose.yml b/compose.yml similarity index 58% rename from docker-compose.yml rename to compose.yml index 4c61a1c9..1276c1af 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -1,19 +1,32 @@ +# +# Use this configuration during development. +# +# This configuration is suitable to access the services using their ports. +# services: db: image: postgres:latest + ports: + - '5431:5432' + restart: unless-stopped + volumes: + - dwengo_postgres_data:/var/lib/postgresql/data environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres - ports: - - '5431:5432' - volumes: - - dwengo_postgres_data:/var/lib/postgresql/data - idp: # Bron: https://medium.com/@fingervinicius/easy-running-keycloak-with-docker-compose-b0d7a4ee2358 + idp: # Based on: https://medium.com/@fingervinicius/easy-running-keycloak-with-docker-compose-b0d7a4ee2358 image: quay.io/keycloak/keycloak:latest + ports: + - '7080:7080' + # - '7443:7443' + command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm'] + restart: unless-stopped volumes: - - ./idp:/opt/keycloak/data/import + - ./config/idp:/opt/keycloak/data/import + depends_on: + - db environment: KC_HOSTNAME: localhost KC_HOSTNAME_PORT: 7080 @@ -22,46 +35,18 @@ services: KC_BOOTSTRAP_ADMIN_PASSWORD: admin KC_HEALTH_ENABLED: 'true' KC_LOG_LEVEL: info - healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:7080/health/ready'] - interval: 15s - timeout: 2s - retries: 15 - command: - [ - 'start-dev', - '--http-port', - '7080', - '--https-port', - '7443', - '--import-realm', - ] - ports: - - '7080:7080' - - '7443:7443' - depends_on: - - db logging: image: grafana/loki:latest ports: - - '3102:3102' + - '9001:3102' - '9095:9095' + command: -config.file=/etc/loki/config.yaml + restart: unless-stopped volumes: - ./config/loki/config.yml:/etc/loki/config.yaml - dwengo_loki_data:/loki - command: -config.file=/etc/loki/config.yaml - restart: unless-stopped - - dashboards: - image: grafana/grafana:latest - ports: - - '3100:3000' - volumes: - - dwengo_grafana_data:/var/lib/grafana - restart: unless-stopped volumes: - dwengo_postgres_data: dwengo_loki_data: - dwengo_grafana_data: + dwengo_postgres_data: diff --git a/idp/README.md b/config/idp/README.md similarity index 100% rename from idp/README.md rename to config/idp/README.md diff --git a/idp/student-realm.json b/config/idp/student-realm.json similarity index 96% rename from idp/student-realm.json rename to config/idp/student-realm.json index e10f6982..32107e4e 100644 --- a/idp/student-realm.json +++ b/config/idp/student-realm.json @@ -407,11 +407,7 @@ "otpPolicyLookAheadWindow": 1, "otpPolicyPeriod": 30, "otpPolicyCodeReusable": false, - "otpSupportedApplications": [ - "totpAppFreeOTPName", - "totpAppGoogleName", - "totpAppMicrosoftAuthenticatorName" - ], + "otpSupportedApplications": ["totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName"], "localizationTexts": {}, "webAuthnPolicyRpEntityName": "keycloak", "webAuthnPolicySignatureAlgorithms": ["ES256", "RS256"], @@ -507,21 +503,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "854c221b-630c-4cc3-9365-bd254246dd69", @@ -563,21 +546,8 @@ "config": {} } ], - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "9449aa8b-d5cc-4b9f-bb01-be1e5a896f2f", @@ -606,21 +576,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "befe3d72-8102-49a6-8268-bce6def58159", @@ -648,21 +605,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "714243ae-72cc-4c26-842a-047357b5919a", @@ -680,7 +624,10 @@ "urn:ietf:wg:oauth:2.0:oob", "http://localhost:5173/*", "http://localhost:5173", - "http://localhost:3000/api-docs/oauth2-redirect.html" + "http://localhost/*", + "http://localhost", + "https://sel2-1.ugent.be/*", + "https://sel2-1.ugent.be" ], "webOrigins": ["+"], "notBefore": 0, @@ -708,21 +655,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "0b06aaa3-717d-4a52-ab46-295a6571b642", @@ -750,21 +684,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "dfc7248c-3794-4e3b-aed2-3ee553cd0feb", @@ -815,21 +736,8 @@ } } ], - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] } ], "clientScopes": [ @@ -1512,23 +1420,8 @@ ] } ], - "defaultDefaultClientScopes": [ - "role_list", - "saml_organization", - "profile", - "email", - "roles", - "web-origins", - "acr", - "basic" - ], - "defaultOptionalClientScopes": [ - "offline_access", - "address", - "phone", - "microprofile-jwt", - "organization" - ], + "defaultDefaultClientScopes": ["role_list", "saml_organization", "profile", "email", "roles", "web-origins", "acr", "basic"], + "defaultOptionalClientScopes": ["offline_access", "address", "phone", "microprofile-jwt", "organization"], "browserSecurityHeaders": { "contentSecurityPolicyReportOnly": "", "xContentTypeOptions": "nosniff", diff --git a/idp/teacher-realm.json b/config/idp/teacher-realm.json similarity index 96% rename from idp/teacher-realm.json rename to config/idp/teacher-realm.json index 5786187c..b9d29dcb 100644 --- a/idp/teacher-realm.json +++ b/config/idp/teacher-realm.json @@ -407,11 +407,7 @@ "otpPolicyLookAheadWindow": 1, "otpPolicyPeriod": 30, "otpPolicyCodeReusable": false, - "otpSupportedApplications": [ - "totpAppFreeOTPName", - "totpAppGoogleName", - "totpAppMicrosoftAuthenticatorName" - ], + "otpSupportedApplications": ["totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName"], "localizationTexts": {}, "webAuthnPolicyRpEntityName": "keycloak", "webAuthnPolicySignatureAlgorithms": ["ES256", "RS256"], @@ -507,21 +503,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "920e8621-36b5-4046-b1cd-4b293668f64b", @@ -563,21 +546,8 @@ "config": {} } ], - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "9d7b2827-b7bb-451e-ad38-8f55a69f7c9c", @@ -606,21 +576,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "cfd0202e-a6b9-4c5e-9f49-2ef17df9089b", @@ -648,21 +605,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "abdee18a-4549-48b5-b976-4c1a42820ef9", @@ -680,7 +624,10 @@ "urn:ietf:wg:oauth:2.0:oob", "http://localhost:5173/*", "http://localhost:5173", - "http://localhost:3000/api-docs/oauth2-redirect.html" + "http://localhost/*", + "http://localhost", + "https://sel2-1.ugent.be/*", + "https://sel2-1.ugent.be" ], "webOrigins": ["+"], "notBefore": 0, @@ -706,21 +653,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", @@ -748,21 +682,8 @@ "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] }, { "id": "c421853c-5bdf-4ea9-ae97-51f5ad7b8df8", @@ -813,21 +734,8 @@ } } ], - "defaultClientScopes": [ - "web-origins", - "acr", - "roles", - "profile", - "basic", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "organization", - "microprofile-jwt" - ] + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] } ], "clientScopes": [ @@ -1510,23 +1418,8 @@ ] } ], - "defaultDefaultClientScopes": [ - "role_list", - "saml_organization", - "profile", - "email", - "roles", - "web-origins", - "acr", - "basic" - ], - "defaultOptionalClientScopes": [ - "offline_access", - "address", - "phone", - "microprofile-jwt", - "organization" - ], + "defaultDefaultClientScopes": ["role_list", "saml_organization", "profile", "email", "roles", "web-origins", "acr", "basic"], + "defaultOptionalClientScopes": ["offline_access", "address", "phone", "microprofile-jwt", "organization"], "browserSecurityHeaders": { "contentSecurityPolicyReportOnly": "", "xContentTypeOptions": "nosniff", diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf new file mode 100644 index 00000000..dc9317f6 --- /dev/null +++ b/config/nginx/nginx.conf @@ -0,0 +1,32 @@ +worker_processes auto; + + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + types { + application/javascript mjs; + text/css; + } + + server { + listen 8080; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + root /usr/share/nginx/html; + expires 1y; + add_header Cache-Control "public"; + try_files $uri =404; + } + } +} diff --git a/docs/architecture/schema.png b/docs/architecture/schema.png index 616d896c..9e4b00ce 100644 Binary files a/docs/architecture/schema.png and b/docs/architecture/schema.png differ diff --git a/docs/architecture/schema.py b/docs/architecture/schema.py index 87a59f9a..7aa4cefd 100644 --- a/docs/architecture/schema.py +++ b/docs/architecture/schema.py @@ -1,30 +1,49 @@ -from diagrams import Cluster, Diagram +from diagrams import Cluster, Diagram, Edge from diagrams.custom import Custom from diagrams.onprem.certificates import LetsEncrypt -from diagrams.onprem.container import Docker from diagrams.onprem.database import PostgreSQL from diagrams.onprem.logging import Loki from diagrams.onprem.monitoring import Grafana from diagrams.onprem.network import Nginx +from diagrams.programming.flowchart import InputOutput from diagrams.programming.framework import Vue from diagrams.programming.language import Nodejs -from diagrams.programming.flowchart import InputOutput with Diagram("Dwengo-1 architectuur", filename="docs/architecture/schema", show=False): - reverse_proxy = Nginx("reverse proxy") - reverse_proxy >> LetsEncrypt("SSL") + ingress = Nginx("Reverse Proxy") + certificates = LetsEncrypt("SSL") - with Cluster("Docker"): - Docker() - - frontend = Vue("/") - backend = Nodejs("/api") - reverse_proxy >> frontend - frontend >> backend >> InputOutput("MikroORM") >> PostgreSQL() - - backend >> Loki("logging") >> Grafana("monitoring") - - with Cluster("Dwengo"): + with Cluster("Dwengo VZW"): dwengo = Custom("Dwengo", "../../assets/img/dwengo-groen-zwart.png") - backend >> dwengo + with Cluster("Dwengo-1"): + frontend = Vue("/") + backend = Nodejs("/api") + identity_provider = Custom("IDP", "../../assets/img/keycloak.png") + + database = PostgreSQL("Database") + orm = InputOutput("MikroORM") + orm >> Edge(label="map") << database + + with Cluster("Observability"): + logging = Loki("Logging") + logging << Edge(color="firebrick", style="dashed") << Grafana("Monitoring") + + dependencies = [ + dwengo, + logging, + orm + ] + + backend >> dependencies + + service = [ + frontend, + backend, + identity_provider, + certificates + ] + + ingress \ + >> Edge(color="darkgreen") \ + << service diff --git a/eslint.config.ts b/eslint.config.ts index b838d8c5..52a36775 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -16,12 +16,7 @@ export default [ prettierConfig, includeIgnoreFile(gitignorePath), { - ignores: [ - '**/dist/**', - '**/.node_modules/**', - '**/coverage/**', - '**/.github/**', - ], + ignores: ['**/dist/**', '**/.node_modules/**', '**/coverage/**', '**/.github/**'], files: ['**/*.ts', '**/*.cts', '**.*.mts', '**/*.ts'], }, { @@ -44,7 +39,10 @@ export default [ 'no-use-before-define': 'error', 'no-useless-assignment': 'error', - 'arrow-body-style': ['warn', 'always'], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'error', + + 'arrow-body-style': ['warn', 'as-needed'], 'block-scoped-var': 'warn', camelcase: 'warn', 'capitalized-comments': 'warn', diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..9cbb61ea --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,36 @@ +FROM node:22 AS build-stage + +# install simple http server for serving static content +RUN npm install -g http-server + +WORKDIR /app + +# Install dependencies + +COPY package*.json ./ +COPY ./frontend/package.json ./frontend/ + +RUN npm install --silent + +# Build the frontend + +# Root tsconfig.json +COPY tsconfig.json ./ +COPY assets ./assets/ + +WORKDIR /app/frontend + +COPY frontend ./ + +RUN npx vite build + +FROM nginx:stable AS production-stage + +COPY config/nginx/nginx.conf /etc/nginx/nginx.conf + +COPY --from=build-stage /app/assets /usr/share/nginx/html/assets +COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 9e68e9c0..e9359af7 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -14,6 +14,9 @@ const vueConfig = defineConfigWithVueTs( { name: "app/files-to-lint", files: ["**/*.{ts,mts,tsx,vue}"], + rules: { + "no-useless-assignment": "off", // Depend on `no-unused-vars` to catch this + }, }, { diff --git a/frontend/package.json b/frontend/package.json index 8c6f81d3..6fb13db7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,11 @@ }, "dependencies": { "vue": "^3.5.13", + "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", "vuetify": "^3.7.12", "oidc-client-ts": "^3.1.0", - "axios": "^1.8.1" + "axios": "^1.8.2" }, "devDependencies": { "@playwright/test": "^1.50.1", diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index fe2b3563..7d7c4d88 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -2,8 +2,10 @@ import { ref } from "vue"; import { useRoute } from "vue-router"; import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; + import { useI18n } from "vue-i18n"; const route = useRoute(); + const { t, locale } = useI18n(); // Instantiate variables to use in html to render right // Links and content dependent on the role (student or teacher) @@ -27,6 +29,8 @@ // Logic to change the language of the website to the selected language const changeLanguage = (langCode: string) => { + locale.value = langCode; + localStorage.setItem("user-lang", langCode); console.log(langCode); }; @@ -46,7 +50,7 @@ :src="dwengoLogo" />

- {{ role }} + {{ t(`${role}`) }}

@@ -55,22 +59,22 @@ :to="`/${role}/${userId}/assignment`" class="menu_item" > - assignments + {{ t("assignments") }}
  • classes{{ t("classes") }}
  • discussions + >{{ t("discussions") }} +
  • @@ -104,7 +108,7 @@