Merge branch 'dev' into chore/docker
This commit is contained in:
commit
441bf990e7
149 changed files with 5420 additions and 780 deletions
12
.github/ISSUE_TEMPLATE/bug-report.md
vendored
12
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
@ -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, ...
|
||||
<!--
|
||||
## Richtlijnen
|
||||
|
||||
Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt:
|
||||
|
||||
- Het is duidelijk waar de bug vandaan komt.
|
||||
- Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
|
||||
- Ik heb de issue toegekend aan de juiste milestone.
|
||||
-->
|
||||
|
|
11
.github/ISSUE_TEMPLATE/feature-request.md
vendored
11
.github/ISSUE_TEMPLATE/feature-request.md
vendored
|
@ -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, ...
|
||||
<!--
|
||||
## Richtlijnen
|
||||
|
||||
Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt:
|
||||
|
||||
- Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
|
||||
- Ik heb de issue toegekend aan de juiste milestone.
|
||||
-->
|
||||
|
|
47
README.md
47
README.md
|
@ -9,29 +9,26 @@ Figma</a></span>
|
|||
Projectopgave</a></span>
|
||||
</p>
|
||||
|
||||
<ul align="center" style="list-style-type: none">
|
||||
<li>Projectleider: Fransisco Gabriel Van Langenhove (<a href="https://github.com/Gabriellvl">@Gabriellvl</a>)</li>
|
||||
<li>Technische lead: Tibo De Peuter (<a href="https://github.com/tdpeuter">@tdpeuter</a>)</li>
|
||||
<li>Systeembeheerder: Timo De Meyst (<a href="https://github.com/kloep1">@kloep1</a>)</li>
|
||||
<li>Customer relations officer: Adriaan Jacquet (<a href="https://github.com/WhisperinCheetah">@WhisperinCheetah</a>)</li>
|
||||
</ul>
|
||||
|
||||
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).
|
||||
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. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar nodig.
|
||||
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).
|
||||
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
|
||||
|
@ -47,7 +44,8 @@ docker compose up
|
|||
|
||||
### 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
|
||||
|
||||
|
@ -59,8 +57,31 @@ De tech-stack bestaat uit:
|
|||
- **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/Developer:-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 <frontend/backend> 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 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| [<img src="https://github.com/WhisperinCheetah.png" width="100px"/><br/><sub><b>Adriaan Jacquet</b></sub>](https://github.com/WhisperinCheetah) | Backend Lead |
|
||||
| [<img src="https://github.com/Gabriellvl.png" width="100px"/><br/><sub><b>Francisco Gabriel Van Langenhove</b></sub>](https://github.com/Gabriellvl) | Team Lead |
|
||||
| [<img src="https://github.com/geraldschmittinger.png" width="100px"/><br/><sub><b>Gerald Schmittinger</b></sub>](https://github.com/geraldschmittinger) | Database Administrator |
|
||||
| [<img src="https://github.com/joyelle436.png" width="100px"/><br/><sub><b>Joyelle Ndagijimana</b></sub>](https://github.com/joyelle436) | Frontend Lead |
|
||||
| [<img src="https://github.com/laurejablonski.png" width="100px"/><br><sub><b>Laure Jablonski</b></sub>](https://github.com/laurejablonski) | Documentatie- en Test Lead |
|
||||
| [<img src="https://github.com/tdpeuter.png" width="100px"/><br/><sub><b>Tibo De Peuter</b></sub>](https://github.com/tdpeuter) | Technische Lead |
|
||||
| [<img src="https://github.com/kloep1.png" width="100px"/><br/><sub><b>Timo De Meyst</b></sub>](https://github.com/kloep1) | System Administrator |
|
||||
|
||||
En in de toekomst misschien jij ook?
|
||||
|
|
|
@ -21,6 +21,14 @@ 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.
|
||||
|
|
|
@ -8,4 +8,14 @@ export default [
|
|||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['tests/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -5,40 +5,46 @@
|
|||
"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",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@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",
|
||||
"uuid": "^11.1.0",
|
||||
"gift-pegjs": "^1.0.2",
|
||||
"isomorphic-dompurify": "^2.22.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"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",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.17.0",
|
||||
"winston-loki": "^6.1.3",
|
||||
"cors": "^2.8.5",
|
||||
"@types/cors": "^2.8.17"
|
||||
"winston-loki": "^6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/js-yaml": "^4.0.9",
|
||||
"globals": "^15.15.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.3",
|
||||
|
|
|
@ -2,8 +2,8 @@ import express, { Express, Response } 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 learningPathRoutes from './routes/learning-paths.js';
|
||||
import learningObjectRoutes from './routes/learning-objects.js';
|
||||
|
||||
import studentRouter from './routes/student.js';
|
||||
import groupRouter from './routes/group.js';
|
||||
|
|
|
@ -1,10 +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';
|
||||
|
|
69
backend/src/controllers/learning-objects.ts
Normal file
69
backend/src/controllers/learning-objects.ts
Normal file
|
@ -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<void> {
|
||||
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<void> {
|
||||
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
|
||||
|
||||
const learningObject = await learningObjectService.getLearningObjectById(learningObjectId);
|
||||
res.json(learningObject);
|
||||
}
|
||||
|
||||
export async function getLearningObjectHTML(req: Request, res: Response): Promise<void> {
|
||||
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
|
||||
|
||||
const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId);
|
||||
res.send(learningObject);
|
||||
}
|
||||
|
||||
export async function getAttachment(req: Request, res: Response): Promise<void> {
|
||||
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);
|
||||
}
|
64
backend/src/controllers/learning-paths.ts
Normal file
64
backend/src/controllers/learning-paths.ts
Normal file
|
@ -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<void> {
|
||||
const hruids = req.query.hruid;
|
||||
const themeKey = req.query.theme as string;
|
||||
const searchQuery = req.query.search as string;
|
||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
||||
|
||||
const 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);
|
||||
}
|
|
@ -1,48 +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<void> {
|
||||
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<void> {
|
||||
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' });
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
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.
|
||||
*/
|
||||
|
@ -11,7 +12,7 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
|
|||
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;
|
||||
|
||||
|
@ -28,14 +29,14 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
|
|||
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) => 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);
|
||||
|
|
|
@ -1,13 +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<Attachment> {
|
||||
public findByLearningObjectAndNumber(learningObject: LearningObject, sequenceNumber: number) {
|
||||
public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> {
|
||||
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<Attachment | null> {
|
||||
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.
|
||||
}
|
||||
|
|
|
@ -1,14 +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<LearningObject> {
|
||||
public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
|
||||
return this.findOne({
|
||||
hruid: identifier.hruid,
|
||||
language: identifier.language,
|
||||
version: identifier.version,
|
||||
});
|
||||
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.
|
||||
}
|
||||
|
|
|
@ -4,7 +4,23 @@ import { Language } from '../../entities/content/language.js';
|
|||
|
||||
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
||||
public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
||||
return this.findOne({ hruid: hruid, language: language });
|
||||
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<LearningPath[]> {
|
||||
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.
|
||||
}
|
||||
|
|
|
@ -5,10 +5,12 @@ import { Teacher } from '../../entities/users/teacher.entity.js';
|
|||
|
||||
export class AnswerRepository extends DwengoEntityRepository<Answer> {
|
||||
public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> {
|
||||
const answerEntity = new Answer();
|
||||
answerEntity.toQuestion = answer.toQuestion;
|
||||
answerEntity.author = answer.author;
|
||||
answerEntity.content = answer.content;
|
||||
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<Answer[]> {
|
||||
|
|
|
@ -5,7 +5,14 @@ import { Student } from '../../entities/users/student.entity.js';
|
|||
|
||||
export class QuestionRepository extends DwengoEntityRepository<Question> {
|
||||
public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> {
|
||||
const questionEntity = new Question();
|
||||
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;
|
||||
|
|
|
@ -28,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;
|
||||
|
||||
|
@ -59,7 +61,7 @@ export const getTeacherRepository = repositoryGetter<Teacher, TeacherRepository>
|
|||
/* Classes */
|
||||
export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class);
|
||||
export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest);
|
||||
export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitationRepository);
|
||||
export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitation);
|
||||
|
||||
/* Assignments */
|
||||
export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment);
|
||||
|
@ -73,4 +75,6 @@ export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(An
|
|||
/* 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 getLearningPathNodeRepository = repositoryGetter(LearningPathNode);
|
||||
export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition);
|
||||
export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Attachment);
|
||||
|
|
|
@ -2,8 +2,9 @@ import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro
|
|||
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: () => Class, primary: true })
|
||||
within!: Class;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
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: () => Assignment,
|
||||
|
|
|
@ -2,8 +2,9 @@ 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;
|
||||
|
@ -14,8 +15,8 @@ export class Submission {
|
|||
})
|
||||
learningObjectLanguage!: Language;
|
||||
|
||||
@PrimaryKey({ type: 'string' })
|
||||
learningObjectVersion: string = '1';
|
||||
@PrimaryKey({ type: 'numeric' })
|
||||
learningObjectVersion: number = 1;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
submissionNumber!: number;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
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: () => Student,
|
||||
|
|
|
@ -2,8 +2,9 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm
|
|||
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();
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
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: () => Teacher,
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
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: () => LearningObject,
|
||||
|
@ -9,8 +10,8 @@ export class Attachment {
|
|||
})
|
||||
learningObject!: LearningObject;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
sequenceNumber!: number;
|
||||
@PrimaryKey({ type: 'string' })
|
||||
name!: string;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
mimeType!: string;
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -4,6 +4,6 @@ export class LearningObjectIdentifier {
|
|||
constructor(
|
||||
public hruid: string,
|
||||
public language: Language,
|
||||
public version: string
|
||||
public version: number
|
||||
) {}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@ import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey,
|
|||
import { Language } from './language.js';
|
||||
import { Attachment } from './attachment.entity.js';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
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 {
|
||||
|
@ -21,7 +24,7 @@ export class ReturnValue {
|
|||
callbackSchema!: string;
|
||||
}
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => LearningObjectRepository })
|
||||
export class LearningObject {
|
||||
@PrimaryKey({ type: 'string' })
|
||||
hruid!: string;
|
||||
|
@ -32,8 +35,11 @@ export class LearningObject {
|
|||
})
|
||||
language!: Language;
|
||||
|
||||
@PrimaryKey({ type: 'string' })
|
||||
version: string = '1';
|
||||
@PrimaryKey({ type: 'number' })
|
||||
version: number = 1;
|
||||
|
||||
@Property({ type: 'uuid', unique: true })
|
||||
uuid = v4();
|
||||
|
||||
@ManyToMany({
|
||||
entity: () => Teacher,
|
||||
|
@ -47,19 +53,19 @@ export class LearningObject {
|
|||
description!: string;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
contentType!: string;
|
||||
contentType!: DwengoContentType;
|
||||
|
||||
@Property({ type: 'array' })
|
||||
keywords: string[] = [];
|
||||
|
||||
@Property({ type: 'array', nullable: true })
|
||||
targetAges?: number[];
|
||||
targetAges?: number[] = [];
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
teacherExclusive: boolean = false;
|
||||
|
||||
@Property({ type: 'array' })
|
||||
skosConcepts!: string[];
|
||||
skosConcepts: string[] = [];
|
||||
|
||||
@Embedded({
|
||||
entity: () => EducationalGoal,
|
||||
|
@ -76,8 +82,8 @@ export class LearningObject {
|
|||
@Property({ type: 'smallint', nullable: true })
|
||||
difficulty?: number;
|
||||
|
||||
@Property({ type: 'integer' })
|
||||
estimatedTime!: number;
|
||||
@Property({ type: 'integer', nullable: true })
|
||||
estimatedTime?: number;
|
||||
|
||||
@Embedded({
|
||||
entity: () => ReturnValue,
|
||||
|
@ -99,12 +105,3 @@ export class LearningObject {
|
|||
@Property({ type: 'blob' })
|
||||
content!: Buffer;
|
||||
}
|
||||
|
||||
export enum ContentType {
|
||||
Markdown = 'text/markdown',
|
||||
Image = 'image/image',
|
||||
Mpeg = 'audio/mpeg',
|
||||
Pdf = 'application/pdf',
|
||||
Extern = 'extern',
|
||||
Blockly = 'Blockly',
|
||||
}
|
||||
|
|
37
backend/src/entities/content/learning-path-node.entity.ts
Normal file
37
backend/src/entities/content/learning-path-node.entity.ts
Normal file
|
@ -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<LearningPath>;
|
||||
|
||||
@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();
|
||||
}
|
|
@ -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<LearningPathNode>;
|
||||
|
||||
@PrimaryKey({ type: 'numeric' })
|
||||
transitionNumber!: number;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
condition!: string;
|
||||
|
||||
@ManyToOne({ entity: () => LearningPathNode })
|
||||
next!: Rel<LearningPathNode>;
|
||||
}
|
|
@ -1,21 +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: () => Language,
|
||||
primary: true,
|
||||
})
|
||||
@Enum({ items: () => Language, primary: true })
|
||||
language!: Language;
|
||||
|
||||
@ManyToMany({
|
||||
entity: () => Teacher,
|
||||
})
|
||||
@ManyToMany({ entity: () => Teacher })
|
||||
admins!: Teacher[];
|
||||
|
||||
@Property({ type: 'string' })
|
||||
|
@ -24,49 +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: () => LearningPathNode,
|
||||
array: true,
|
||||
})
|
||||
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
|
||||
nodes: LearningPathNode[] = [];
|
||||
}
|
||||
|
||||
@Embeddable()
|
||||
export class LearningPathNode {
|
||||
@Property({ type: 'string' })
|
||||
learningObjectHruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => Language,
|
||||
})
|
||||
language!: Language;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
version!: string;
|
||||
|
||||
@Property({ type: 'longtext' })
|
||||
instruction!: string;
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
startNode!: boolean;
|
||||
|
||||
@Embedded({
|
||||
entity: () => LearningPathTransition,
|
||||
array: true,
|
||||
})
|
||||
transitions!: LearningPathTransition[];
|
||||
}
|
||||
|
||||
@Embeddable()
|
||||
export class LearningPathTransition {
|
||||
@Property({ type: 'string' })
|
||||
condition!: string;
|
||||
|
||||
@OneToOne({
|
||||
entity: () => LearningPathNode,
|
||||
})
|
||||
next!: LearningPathNode;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
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: () => Teacher,
|
||||
|
@ -16,8 +17,8 @@ export class Answer {
|
|||
})
|
||||
toQuestion!: Question;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
sequenceNumber!: number;
|
||||
@PrimaryKey({ type: 'integer', autoincrement: true })
|
||||
sequenceNumber?: number;
|
||||
|
||||
@Property({ type: 'datetime' })
|
||||
timestamp: Date = new Date();
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
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;
|
||||
|
@ -13,11 +14,11 @@ export class Question {
|
|||
})
|
||||
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: () => Student,
|
||||
|
|
|
@ -1,9 +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(() => Class)
|
||||
classes!: Collection<Class>;
|
||||
|
||||
constructor(
|
||||
public username: string,
|
||||
public firstName: string,
|
||||
public lastName: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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[];
|
|
@ -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,6 +48,7 @@ function config(testingMode: boolean = false): Options {
|
|||
return {
|
||||
driver: SqliteDriver,
|
||||
dbName: getEnvVar(EnvVars.DbName),
|
||||
subscribers: [new SqliteAutoincrementSubscriber()],
|
||||
entities: entities,
|
||||
// EntitiesTs: entitiesTs,
|
||||
|
||||
|
|
|
@ -1,5 +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();
|
||||
|
||||
|
@ -21,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;
|
|
@ -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();
|
||||
|
23
backend/src/services/learning-objects/attachment-service.ts
Normal file
23
backend/src/services/learning-objects/attachment-service.ts
Normal file
|
@ -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<Attachment | null> {
|
||||
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;
|
|
@ -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<LearningObject | null> {
|
||||
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<FilteredLearningObject | null> {
|
||||
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<string | null> {
|
||||
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<string[]> {
|
||||
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<FilteredLearningObject[]> {
|
||||
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;
|
|
@ -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<FilteredLearningObject[] | string[]> {
|
||||
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<FilteredLearningObject | null> {
|
||||
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
|
||||
const metadata = await fetchWithLogging<LearningObjectMetadata>(
|
||||
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<FilteredLearningObject[]> {
|
||||
return (await fetchLearningObjects(id, true)) as FilteredLearningObject[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
|
||||
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<string | null> {
|
||||
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
|
||||
const html = await fetchWithLogging<string>(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;
|
|
@ -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<FilteredLearningObject | null>;
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
*/
|
||||
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]>;
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null>;
|
||||
}
|
|
@ -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<FilteredLearningObject | null> {
|
||||
return getProvider(id).getLearningObjectById(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
*/
|
||||
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
|
||||
return getProvider(id).getLearningObjectsFromPath(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
|
||||
return getProvider(id).getLearningObjectIdsFromPath(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
return getProvider(id).getLearningObjectHTML(id);
|
||||
},
|
||||
};
|
||||
|
||||
export default learningObjectService;
|
|
@ -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(`<audio controls>
|
||||
<source src="${audioUrl}" type=${type}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>`);
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioProcessor;
|
|
@ -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 };
|
40
backend/src/services/learning-objects/processing/extern/extern-processor.ts
vendored
Normal file
40
backend/src/services/learning-objects/processing/extern/extern-processor.ts
vendored
Normal file
|
@ -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(
|
||||
`
|
||||
<div class="iframe-container">
|
||||
<iframe src="${externURL}" allowfullscreen></iframe>
|
||||
</div>`,
|
||||
{ ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExternProcessor;
|
|
@ -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 = "<div class='learning-object-gift'>\n";
|
||||
let i = 1;
|
||||
for (const question of quizQuestions) {
|
||||
html += ` <div class='gift-question' id='gift-q${i}'>\n`;
|
||||
html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation.
|
||||
html += ` </div>\n`;
|
||||
i++;
|
||||
}
|
||||
html += '</div>\n';
|
||||
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
private renderQuestion<T extends GIFTQuestion>(question: T, questionNumber: number): string {
|
||||
const renderer = this.renderers[question.type] as GIFTQuestionRenderer<T>;
|
||||
return renderer.render(question, questionNumber);
|
||||
}
|
||||
}
|
||||
|
||||
type RendererMap = {
|
||||
[K in GIFTQuestion['type']]: GIFTQuestionRenderer<Extract<GIFTQuestion, { type: K }>>;
|
||||
};
|
||||
|
||||
export default GiftProcessor;
|
|
@ -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<Category> {
|
||||
render(question: Category, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Category' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -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<Description> {
|
||||
render(question: Description, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Description' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { Essay } from 'gift-pegjs';
|
||||
|
||||
export class EssayQuestionRenderer extends GIFTQuestionRenderer<Essay> {
|
||||
render(question: Essay, questionNumber: number): string {
|
||||
let renderedHtml = '';
|
||||
if (question.title) {
|
||||
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
|
||||
}
|
||||
if (question.stem) {
|
||||
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
|
||||
}
|
||||
renderedHtml += `<textarea class='gift-essay-answer' id='gift-q${questionNumber}-answer'></textarea>\n`;
|
||||
return renderedHtml;
|
||||
}
|
||||
}
|
|
@ -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<T extends GIFTQuestion> {
|
||||
/**
|
||||
* 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;
|
||||
}
|
|
@ -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<Matching> {
|
||||
render(question: Matching, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Matching' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { MultipleChoice } from 'gift-pegjs';
|
||||
|
||||
export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<MultipleChoice> {
|
||||
render(question: MultipleChoice, questionNumber: number): string {
|
||||
let renderedHtml = '';
|
||||
if (question.title) {
|
||||
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
|
||||
}
|
||||
if (question.stem) {
|
||||
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
|
||||
}
|
||||
let i = 0;
|
||||
for (const choice of question.choices) {
|
||||
renderedHtml += `<div class="gift-choice-div">\n`;
|
||||
renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`;
|
||||
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`;
|
||||
renderedHtml += `</div>\n`;
|
||||
i++;
|
||||
}
|
||||
return renderedHtml;
|
||||
}
|
||||
}
|
|
@ -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<Numerical> {
|
||||
render(question: Numerical, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Numerical' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -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<ShortAnswer> {
|
||||
render(question: ShortAnswer, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -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<TrueFalse> {
|
||||
render(question: TrueFalse, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'TrueFalse' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -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(`<div>${inlineHtml}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
export default BlockImageProcessor;
|
|
@ -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(`<img src="${imageUrl}" alt="">`);
|
||||
}
|
||||
}
|
||||
|
||||
export default InlineImageProcessor;
|
|
@ -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 (
|
||||
`<h${level}>\n` +
|
||||
` <a name="${escapedText}" class="anchor" href="#${escapedText}">\n` +
|
||||
` <span class="header-link"></span>\n` +
|
||||
` </a>\n` +
|
||||
` ${text}\n` +
|
||||
`</h${level}>\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 `<a href="${getUrlStringForLearningObjectHTML(learningObjectId)}" target="_blank" title="${title}">${text}</a>`;
|
||||
}
|
||||
// Any other link
|
||||
if (!isValidHttpUrl(href)) {
|
||||
throw new ProcessingError('Link is not a valid HTTP URL!');
|
||||
}
|
||||
//<a href="https://kiks.ilabt.imec.be/hub/tmplogin?id=0101" title="Notebooks Werking"><img src="Knop.png" alt="" title="Knop"></a>
|
||||
return `<a href="${href}" target="_blank" title="${title}">${text}</a>`;
|
||||
},
|
||||
|
||||
// When the syntax for an image is used => 
|
||||
// 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 `
|
||||
<learning-object hruid="${learningObjectId.hruid}" language="${learningObjectId.language}" version="${learningObjectId.version}"/>
|
||||
`; // 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;
|
|
@ -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(
|
||||
/<img.*?src="(.*?)".*?(alt="(.*?)")?.*?(title="(.*?)")?.*?>/g,
|
||||
(match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src)
|
||||
);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
export { MarkdownProcessor };
|
|
@ -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(
|
||||
`
|
||||
<embed src="${pdfUrl}" type="application/pdf" width="100%" height="800px"/>
|
||||
`,
|
||||
{ ADD_TAGS: ['embed'] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PdfProcessor;
|
|
@ -0,0 +1,5 @@
|
|||
export class ProcessingError extends Error {
|
||||
constructor(error: string) {
|
||||
super(error);
|
||||
}
|
||||
}
|
|
@ -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 = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g;
|
||||
const LEARNING_OBJECT_DOES_NOT_EXIST = "<div class='non-existing-learning-object' />";
|
||||
|
||||
class ProcessingService {
|
||||
private processors!: Map<DwengoContentType, Processor<any>>;
|
||||
|
||||
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<LearningObject | null>
|
||||
): Promise<string> {
|
||||
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<string> => {
|
||||
// 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();
|
|
@ -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<T> {
|
||||
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;
|
|
@ -0,0 +1,19 @@
|
|||
import Processor from './processor.js';
|
||||
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
|
||||
|
||||
export abstract class StringProcessor extends Processor<string> {
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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<Map<LearningPathNode, FilteredLearningObject>> {
|
||||
// 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<LearningPathNode, FilteredLearningObject | null>(
|
||||
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<LearningPathNode, FilteredLearningObject>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<LearningPath> {
|
||||
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = 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<LearningPathNode, FilteredLearningObject>,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
): Promise<LearningObjectNode[]> {
|
||||
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<LearningPathNode, FilteredLearningObject>
|
||||
): 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<LearningPathResponse> {
|
||||
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<LearningPath[]> {
|
||||
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;
|
|
@ -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<LearningPathResponse> {
|
||||
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<LearningPath[]>(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<LearningPath[]> {
|
||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
|
||||
const params = { all: query, language };
|
||||
|
||||
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
|
||||
return searchResults ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
export default dwengoApiLearningPathProvider;
|
|
@ -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<PersonalizationTarget | undefined> {
|
||||
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<PersonalizationTarget | undefined> {
|
||||
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<Submission | null> {
|
||||
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;
|
||||
}
|
|
@ -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<LearningPathResponse>;
|
||||
|
||||
/**
|
||||
* Search learning paths in the data source using the given search string.
|
||||
*/
|
||||
searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>;
|
||||
}
|
57
backend/src/services/learning-paths/learning-path-service.ts
Normal file
57
backend/src/services/learning-paths/learning-path-service.ts
Normal file
|
@ -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<LearningPathResponse> {
|
||||
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<LearningPath[]> {
|
||||
const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor)));
|
||||
return providerResponses.flat();
|
||||
},
|
||||
};
|
||||
|
||||
export default learningPathService;
|
|
@ -1,91 +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<FilteredLearningObject | null> {
|
||||
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`;
|
||||
const metadata = await fetchWithLogging<LearningObjectMetadata>(
|
||||
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<FilteredLearningObject[] | string[]> {
|
||||
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) => node.learningobject_hruid);
|
||||
}
|
||||
|
||||
return await Promise.all(nodes.map(async (node) => getLearningObjectById(node.learningobject_hruid, language))).then((objects) =>
|
||||
objects.filter((obj): obj is FilteredLearningObject => 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<FilteredLearningObject[]> {
|
||||
return (await fetchLearningObjects(hruid, true, language)) as FilteredLearningObject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise<string[]> {
|
||||
return (await fetchLearningObjects(hruid, false, language)) as string[];
|
||||
}
|
|
@ -1,46 +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<LearningPathResponse> {
|
||||
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<LearningPath[]>(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<LearningPath[]> {
|
||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
|
||||
const params = { all: query, language };
|
||||
|
||||
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, params);
|
||||
return searchResults ?? [];
|
||||
}
|
41
backend/src/sqlite-autoincrement-workaround.ts
Normal file
41
backend/src/sqlite-autoincrement-workaround.ts
Normal file
|
@ -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<string, number> = 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<T extends object>(args: EventArgs<T>): 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<T>;
|
||||
if (property.primary && property.autoincrement && !(args.entity as Record<string, any>)[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<string, any>)[property.name] = nextSeqNumber + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,13 +9,21 @@ 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<T>(url: string, description: string, params?: Record<string, any>): Promise<T | null> {
|
||||
export async function fetchWithLogging<T>(
|
||||
url: string,
|
||||
description: string,
|
||||
options?: {
|
||||
params?: Record<string, any>;
|
||||
query?: Record<string, any>;
|
||||
responseType?: 'json' | 'text';
|
||||
}
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const config: AxiosRequestConfig = params ? { params } : {};
|
||||
|
||||
const config: AxiosRequestConfig = options || {};
|
||||
const response = await axios.get<T>(url, config);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
|
23
backend/src/util/async.ts
Normal file
23
backend/src/util/async.ts
Normal file
|
@ -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<string>) {
|
||||
const promises: Promise<string>[] = [];
|
||||
|
||||
// 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()!);
|
||||
}
|
|
@ -15,6 +15,9 @@ 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 },
|
||||
|
|
26
backend/src/util/links.ts
Normal file
26
backend/src/util/links.ts
Normal file
|
@ -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;
|
||||
}
|
42
backend/tests/data/assignments/assignments.test.ts
Normal file
42
backend/tests/data/assignments/assignments.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
49
backend/tests/data/assignments/groups.test.ts
Normal file
49
backend/tests/data/assignments/groups.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
70
backend/tests/data/assignments/submissions.test.ts
Normal file
70
backend/tests/data/assignments/submissions.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
47
backend/tests/data/classes/class-join-request.test.ts
Normal file
47
backend/tests/data/classes/class-join-request.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
34
backend/tests/data/classes/classes.test.ts
Normal file
34
backend/tests/data/classes/classes.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
54
backend/tests/data/classes/teacher-invitation.test.ts
Normal file
54
backend/tests/data/classes/teacher-invitation.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
79
backend/tests/data/content/attachment-repository.test.ts
Normal file
79
backend/tests/data/content/attachment-repository.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
31
backend/tests/data/content/attachments.test.ts
Normal file
31
backend/tests/data/content/attachments.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
32
backend/tests/data/content/learning-objects.test.ts
Normal file
32
backend/tests/data/content/learning-objects.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
66
backend/tests/data/content/learning-path-repository.test.ts
Normal file
66
backend/tests/data/content/learning-path-repository.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
28
backend/tests/data/content/learning-paths.test.ts
Normal file
28
backend/tests/data/content/learning-paths.test.ts
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
66
backend/tests/data/questions/answers.test.ts
Normal file
66
backend/tests/data/questions/answers.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
52
backend/tests/data/questions/questions.test.ts
Normal file
52
backend/tests/data/questions/questions.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -1,8 +1,8 @@
|
|||
import { setupTestApp } from '../setup-tests.js';
|
||||
import { Student } from '../../src/entities/users/student.entity.js';
|
||||
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';
|
||||
import { StudentRepository } from '../../../src/data/users/student-repository.js';
|
||||
import { getStudentRepository } from '../../../src/data/repositories.js';
|
||||
|
||||
const username = 'teststudent';
|
||||
const firstName = 'John';
|
||||
|
@ -15,6 +15,20 @@ describe('StudentRepository', () => {
|
|||
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));
|
||||
|
47
backend/tests/data/users/teachers.test.ts
Normal file
47
backend/tests/data/users/teachers.test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
117
backend/tests/service/learning-objects.test.ts
Normal file
117
backend/tests/service/learning-objects.test.ts
Normal file
|
@ -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([]);
|
||||
});
|
||||
});
|
90
backend/tests/service/learning-paths.test.ts
Normal file
90
backend/tests/service/learning-paths.test.ts
Normal file
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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());
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue