Merge fix/progress-bar into feat/232-assignments-pagina-ui-ux

This commit is contained in:
Joyelle Ndagijimana 2025-05-17 01:44:37 +02:00
commit 368130c431
149 changed files with 4429 additions and 1120 deletions

View file

@ -8,6 +8,7 @@
### Dwengo ### ### Dwengo ###
DWENGO_PORT=3000 DWENGO_PORT=3000
DWENGO_RUN_MODE=test
DWENGO_DB_NAME=":memory:" DWENGO_DB_NAME=":memory:"
DWENGO_DB_UPDATE=true DWENGO_DB_UPDATE=true

View file

@ -29,6 +29,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.0.1", "express": "^5.0.1",
"express-fileupload": "^1.5.1",
"express-jwt": "^8.5.1", "express-jwt": "^8.5.1",
"gift-pegjs": "^1.0.2", "gift-pegjs": "^1.0.2",
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
@ -37,8 +38,11 @@
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"loki-logger-ts": "^1.0.2", "loki-logger-ts": "^1.0.2",
"marked": "^15.0.7", "marked": "^15.0.7",
"mime-types": "^3.0.1",
"nanoid": "^5.1.5",
"response-time": "^2.3.3", "response-time": "^2.3.3",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"unzipper": "^0.12.3",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-loki": "^6.1.3" "winston-loki": "^6.1.3"
@ -47,10 +51,13 @@
"@mikro-orm/cli": "6.4.12", "@mikro-orm/cli": "6.4.12",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-fileupload": "^1.5.1",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/response-time": "^2.3.8", "@types/response-time": "^2.3.8",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/unzipper": "^0.10.11",
"globals": "^15.15.0", "globals": "^15.15.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.3", "tsx": "^4.19.3",

View file

@ -1,10 +1,11 @@
import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; import { UnauthorizedException } from '../exceptions/unauthorized-exception.js';
import { getLogger } from '../logging/initalize.js'; import { getLogger } from '../logging/initalize.js';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js';
import { createOrUpdateStudent } from '../services/students.js';
import { createOrUpdateTeacher } from '../services/teachers.js';
import { envVars, getEnvVar } from '../util/envVars.js'; import { envVars, getEnvVar } from '../util/envVars.js';
import { Response } from 'express'; import { createOrUpdateStudent } from '../services/students.js';
import { Request, Response } from 'express';
import { createOrUpdateTeacher } from '../services/teachers.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
interface FrontendIdpConfig { interface FrontendIdpConfig {
authority: string; authority: string;
@ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig {
}; };
} }
export function handleGetFrontendAuthConfig(_req: Request, res: Response): void {
res.json(getFrontendAuthConfig());
}
export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> { export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> {
const auth = req.auth; const auth = req.auth;
if (!auth) { if (!auth) {
@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response)
firstName: auth.firstName ?? '', firstName: auth.firstName ?? '',
lastName: auth.lastName ?? '', lastName: auth.lastName ?? '',
}; };
if (auth.accountType === 'student') { if (auth.accountType === AccountType.Student) {
await createOrUpdateStudent(userData); await createOrUpdateStudent(userData);
logger.debug(`Synchronized student ${userData.username} with IDP`); logger.debug(`Synchronized student ${userData.username} with IDP`);
} else { } else {

View file

@ -7,6 +7,9 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
import { envVars, getEnvVar } from '../util/envVars.js'; import { envVars, getEnvVar } from '../util/envVars.js';
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import { UploadedFile } from 'express-fileupload';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request';
import { requireFields } from './error-helper.js';
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO {
if (!req.params.hruid) { if (!req.params.hruid) {
@ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde
} }
function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier {
if (!req.query.hruid) { const { hruid, language } = req.params;
throw new BadRequestException('HRUID is required.'); requireFields({ hruid });
}
return { return {
hruid: req.params.hruid, hruid,
language: (req.query.language as Language) || FALLBACK_LANG, language: (language as Language) || FALLBACK_LANG,
}; };
} }
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
const learningPathId = getLearningPathIdentifierFromRequest(req); if (req.query.admin) {
const full = req.query.full; // If the admin query parameter is present, the user wants to have all learning objects with this admin.
const learningObjects = await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string);
let learningObjects: FilteredLearningObject[] | string[]; res.json(learningObjects);
if (full) {
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
} else { } else {
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); // Else he/she wants all learning objects on the path specified by the request parameters.
} const learningPathId = getLearningPathIdentifierFromRequest(req);
const full = req.query.full;
res.json({ learningObjects: learningObjects }); let learningObjects: FilteredLearningObject[] | string[];
if (full) {
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
} else {
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId);
}
res.json({ learningObjects: learningObjects });
}
} }
export async function getLearningObject(req: Request, res: Response): Promise<void> { export async function getLearningObject(req: Request, res: Response): Promise<void> {
@ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise<void>
} }
res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); res.setHeader('Content-Type', attachment.mimeType).send(attachment.content);
} }
export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> {
if (!req.files || !req.files.learningObject) {
throw new BadRequestException('No file uploaded');
}
const learningObject = await learningObjectService.storeLearningObject((req.files.learningObject as UploadedFile).tempFilePath, [
req.auth!.username,
]);
res.json(learningObject);
}
export async function handleDeleteLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> {
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
if (!learningObjectId.version) {
throw new BadRequestException('When deleting a learning object, a version must be specified.');
}
const deletedLearningObject = await learningObjectService.deleteLearningObject({
hruid: learningObjectId.hruid,
version: learningObjectId.version,
language: learningObjectId.language,
});
if (deletedLearningObject) {
res.json(deletedLearningObject);
} else {
throw new NotFoundException('Learning object not found');
}
}

View file

@ -7,51 +7,103 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
import { Group } from '../entities/assignments/group.entity.js'; import { Group } from '../entities/assignments/group.entity.js';
import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js';
import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import { getTeacher } from '../services/teachers.js';
import { requireFields } from './error-helper.js';
/** /**
* Fetch learning paths based on query parameters. * Fetch learning paths based on query parameters.
*/ */
export async function getLearningPaths(req: Request, res: Response): Promise<void> { export async function getLearningPaths(req: Request, res: Response): Promise<void> {
const hruids = req.query.hruid; const admin = req.query.admin;
const themeKey = req.query.theme as string; if (admin) {
const searchQuery = req.query.search as string; const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string);
const language = (req.query.language as string) || FALLBACK_LANG; res.json(paths);
const forGroupNo = req.query.forGroup as string;
const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string;
let forGroup: Group | undefined;
if (forGroupNo) {
if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
}
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
if (assignment) {
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
}
}
let hruidList;
if (hruids) {
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
} else if (themeKey) {
const theme = themes.find((t) => t.title === themeKey);
if (theme) {
hruidList = theme.hruids;
} else {
throw new NotFoundException(`Theme "${themeKey}" not found.`);
}
} else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
res.json(searchResults);
return;
} else { } else {
hruidList = themes.flatMap((theme) => theme.hruids); const hruids = req.query.hruid;
} const themeKey = req.query.theme as string;
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); const forGroupNo = req.query.forGroup as string;
res.json(learningPaths.data); const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string;
let forGroup: Group | undefined;
if (forGroupNo) {
if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
}
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
if (assignment) {
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
}
}
let hruidList;
if (hruids) {
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
} else if (themeKey) {
const theme = themes.find((t) => t.title === themeKey);
if (theme) {
hruidList = theme.hruids;
} else {
throw new NotFoundException(`Theme "${themeKey}" not found.`);
}
} else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
res.json(searchResults);
return;
} else {
hruidList = themes.flatMap((theme) => theme.hruids);
}
const learningPaths = await learningPathService.fetchLearningPaths(
hruidList,
language as Language,
`HRUIDs: ${hruidList.join(', ')}`,
forGroup
);
res.json(learningPaths.data);
}
}
function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise<void> {
return async (req, res) => {
const path = req.body as LearningPath;
const { hruid: hruidParam, language: languageParam } = req.params;
if (isPut) {
requireFields({ hruidParam, languageParam, path });
}
const teacher = await getTeacher(req.auth!.username);
if (isPut) {
if (req.params.hruid !== path.hruid || req.params.language !== path.language) {
throw new BadRequestException('id_not_matching_query_params');
}
await learningPathService.deleteLearningPath({ hruid: path.hruid, language: path.language as Language });
}
res.json(await learningPathService.createNewLearningPath(path, [teacher]));
};
}
export const postLearningPath = postOrPutLearningPath(false);
export const putLearningPath = postOrPutLearningPath(true);
export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise<void> {
const { hruid, language } = req.params;
requireFields({ hruid, language });
const id: LearningPathIdentifier = { hruid, language: language as Language };
const deletedPath = await learningPathService.deleteLearningPath(id);
if (deletedPath) {
res.json(deletedPath);
} else {
throw new NotFoundException('The learning path could not be found.');
}
} }

View file

@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response):
const classId = req.body.classId; const classId = req.body.classId;
requireFields({ username, classId }); requireFields({ username, classId });
const request = await createClassJoinRequest(username, classId); const request = await createClassJoinRequest(username, classId.toUpperCase());
res.json({ request }); res.json({ request });
} }

View file

@ -62,6 +62,11 @@ export async function getAllSubmissionsHandler(req: Request, res: Response): Pro
// TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden // TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden
export async function createSubmissionHandler(req: Request, res: Response): Promise<void> { export async function createSubmissionHandler(req: Request, res: Response): Promise<void> {
const submitter = req.body.submitter;
const usernameSubmitter = req.body.submitter.username;
const group = req.body.group;
requireFields({ group, submitter, usernameSubmitter });
const submissionDTO = req.body as SubmissionDTO; const submissionDTO = req.body as SubmissionDTO;
const submission = await createSubmission(submissionDTO); const submission = await createSubmission(submissionDTO);

View file

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { requireFields } from './error-helper.js'; import { requireFields } from './error-helper.js';
import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js';
import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> { export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username; const username = req.params.username;
@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom
const classId = req.body.class; const classId = req.body.class;
requireFields({ sender, receiver, classId }); requireFields({ sender, receiver, classId });
if (sender === receiver) {
throw new ConflictException('Cannot send an invitation to yourself');
}
const data = req.body as TeacherInvitationData; const data = req.body as TeacherInvitationData;
const invitation = await createInvitation(data); const invitation = await createInvitation(data);

View file

@ -81,16 +81,6 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro
res.json({ students }); res.json({ students });
} }
export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';
requireFields({ username });
const questions = await getTeacherQuestions(username, full);
res.json({ questions });
}
export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> { export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> {
const classId = req.params.classId; const classId = req.params.classId;
requireFields({ classId }); requireFields({ classId });

View file

@ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { Teacher } from '../../entities/users/teacher.entity.js';
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
@ -13,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
version: identifier.version, version: identifier.version,
}, },
{ {
populate: ['keywords'], populate: ['keywords', 'admins'],
} }
); );
} }
@ -33,10 +32,22 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
); );
} }
public async findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> { public async findAllByAdmin(adminUsername: string): Promise<LearningObject[]> {
return this.find( return this.find(
{ admins: teacher }, {
admins: {
username: adminUsername,
},
},
{ populate: ['admins'] } // Make sure to load admin relations { populate: ['admins'] } // Make sure to load admin relations
); );
} }
public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
const learningObject = await this.findByIdentifier(identifier);
if (learningObject) {
await this.em.removeAndFlush(learningObject);
}
return learningObject;
}
} }

View file

@ -4,11 +4,10 @@ import { Language } from '@dwengo-1/common/util/language';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { RequiredEntityData } from '@mikro-orm/core'; import { RequiredEntityData } from '@mikro-orm/core';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js';
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] });
} }
/** /**
@ -24,7 +23,21 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
language: language, language: language,
$or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }],
}, },
populate: ['nodes', 'nodes.transitions'], populate: ['nodes', 'nodes.transitions', 'admins'],
});
}
/**
* Returns all learning paths which have the user with the given username as an administrator.
*/
public async findAllByAdminUsername(adminUsername: string): Promise<LearningPath[]> {
return this.findAll({
where: {
admins: {
username: adminUsername,
},
},
populate: ['nodes', 'nodes.transitions', 'admins'],
}); });
} }
@ -36,18 +49,15 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
return this.em.create(LearningPathTransition, transitionData); return this.em.create(LearningPathTransition, transitionData);
} }
public async saveLearningPathNodesAndTransitions( /**
path: LearningPath, * Deletes the learning path with the given hruid and language.
nodes: LearningPathNode[], * @returns the deleted learning path or null if it was not found.
transitions: LearningPathTransition[], */
options?: { preventOverwrite?: boolean } public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
): Promise<void> { const path = await this.findByHruidAndLanguage(hruid, language);
if (options?.preventOverwrite && (await this.findOne(path))) { if (path) {
throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); await this.em.removeAndFlush(path);
} }
const em = this.getEntityManager(); return path;
await em.persistAndFlush(path);
await Promise.all(nodes.map(async (it) => em.persistAndFlush(it)));
await Promise.all(transitions.map(async (it) => em.persistAndFlush(it)));
} }
} }

View file

@ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Student } from '../../entities/users/student.entity.js'; import { Student } from '../../entities/users/student.entity.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { Group } from '../../entities/assignments/group.entity.js';
import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js';
import { Loaded } from '@mikro-orm/core'; import { Loaded } from '@mikro-orm/core';
import { Group } from '../../entities/assignments/group.entity';
export class QuestionRepository extends DwengoEntityRepository<Question> { export class QuestionRepository extends DwengoEntityRepository<Question> {
public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> {

View file

@ -26,6 +26,9 @@ export class Assignment {
@Property({ type: 'string' }) @Property({ type: 'string' })
learningPathHruid!: string; learningPathHruid!: string;
@Property({ type: 'datetime', nullable: true })
deadline?: Date;
@Enum({ @Enum({
items: () => Language, items: () => Language,
}) })

View file

@ -1,15 +1,17 @@
import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { v4 } from 'uuid';
import { Teacher } from '../users/teacher.entity.js'; import { Teacher } from '../users/teacher.entity.js';
import { Student } from '../users/student.entity.js'; import { Student } from '../users/student.entity.js';
import { ClassRepository } from '../../data/classes/class-repository.js'; import { ClassRepository } from '../../data/classes/class-repository.js';
import { customAlphabet } from 'nanoid';
const generateClassId = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@Entity({ @Entity({
repository: () => ClassRepository, repository: () => ClassRepository,
}) })
export class Class { export class Class {
@PrimaryKey() @PrimaryKey()
classId? = v4(); classId? = generateClassId();
@Property({ type: 'string' }) @Property({ type: 'string' })
displayName!: string; displayName!: string;

View file

@ -9,6 +9,7 @@ export class Attachment {
@ManyToOne({ @ManyToOne({
entity: () => LearningObject, entity: () => LearningObject,
primary: true, primary: true,
deleteRule: 'cascade',
}) })
learningObject!: LearningObject; learningObject!: LearningObject;

View file

@ -1,4 +1,4 @@
import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { ArrayType, Collection, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { Attachment } from './attachment.entity.js'; import { Attachment } from './attachment.entity.js';
import { Teacher } from '../users/teacher.entity.js'; import { Teacher } from '../users/teacher.entity.js';
import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js';
@ -28,7 +28,7 @@ export class LearningObject {
@ManyToMany({ @ManyToMany({
entity: () => Teacher, entity: () => Teacher,
}) })
admins!: Teacher[]; admins: Collection<Teacher> = new Collection<Teacher>(this);
@Property({ type: 'string' }) @Property({ type: 'string' })
title!: string; title!: string;
@ -84,7 +84,7 @@ export class LearningObject {
entity: () => Attachment, entity: () => Attachment,
mappedBy: 'learningObject', mappedBy: 'learningObject',
}) })
attachments: Attachment[] = []; attachments: Collection<Attachment> = new Collection<Attachment>(this);
@Property({ type: 'blob' }) @Property({ type: 'blob' })
content!: Buffer; content!: Buffer;

View file

@ -1,4 +1,4 @@
import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
import { LearningPath } from './learning-path.entity.js'; import { LearningPath } from './learning-path.entity.js';
import { LearningPathTransition } from './learning-path-transition.entity.js'; import { LearningPathTransition } from './learning-path-transition.entity.js';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
@ -26,7 +26,7 @@ export class LearningPathNode {
@Property({ type: 'bool' }) @Property({ type: 'bool' })
startNode!: boolean; startNode!: boolean;
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] })
transitions!: Collection<LearningPathTransition>; transitions!: Collection<LearningPathTransition>;
@Property({ length: 3 }) @Property({ length: 3 })

View file

@ -1,4 +1,4 @@
import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Cascade, Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { Teacher } from '../users/teacher.entity.js'; import { Teacher } from '../users/teacher.entity.js';
import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
import { LearningPathNode } from './learning-path-node.entity.js'; import { LearningPathNode } from './learning-path-node.entity.js';
@ -24,6 +24,6 @@ export class LearningPath {
@Property({ type: 'blob', nullable: true }) @Property({ type: 'blob', nullable: true })
image: Buffer | null = null; image: Buffer | null = null;
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] })
nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this); nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this);
} }

View file

@ -20,6 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO {
description: assignment.description, description: assignment.description,
learningPath: assignment.learningPathHruid, learningPath: assignment.learningPathHruid,
language: assignment.learningPathLanguage, language: assignment.learningPathLanguage,
deadline: assignment.deadline ?? new Date(),
groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)),
}; };
} }
@ -31,6 +32,7 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi
description: assignmentData.description, description: assignmentData.description,
learningPathHruid: assignmentData.learningPath, learningPathHruid: assignmentData.learningPath,
learningPathLanguage: languageMap[assignmentData.language], learningPathLanguage: languageMap[assignmentData.language],
deadline: assignmentData.deadline,
groups: [], groups: [],
}); });
} }

View file

@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO {
}; };
} }
export function mapToUsername(user: { username: string }): string {
return user.username;
}
export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T { export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T {
userInstance.username = userData.username; userInstance.username = userData.username;
userInstance.firstName = userData.firstName; userInstance.firstName = userData.firstName;

View file

@ -7,7 +7,6 @@ import * as express from 'express';
import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticatedRequest } from './authenticated-request.js';
import { AuthenticationInfo } from './authentication-info.js'; import { AuthenticationInfo } from './authentication-info.js';
import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../exceptions/forbidden-exception.js';
const JWKS_CACHE = true; const JWKS_CACHE = true;
const JWKS_RATE_LIMIT = true; const JWKS_RATE_LIMIT = true;
@ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response
} }
export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; export const authenticateUser = [verifyJwtToken, addAuthenticationInfo];
/**
* Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill
* the given access condition.
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
* to true.
*/
export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) {
return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => {
if (!req.auth) {
throw new UnauthorizedException();
} else if (!accessCondition(req.auth)) {
throw new ForbiddenException();
} else {
next();
}
};
}
/**
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
*/
export const authenticatedOnly = authorize((_) => true);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't students.
*/
export const studentsOnly = authorize((auth) => auth.accountType === 'student');
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === 'teacher');

View file

@ -1,8 +1,15 @@
import { Request } from 'express'; import { Request } from 'express';
import { JwtPayload } from 'jsonwebtoken'; import { JwtPayload } from 'jsonwebtoken';
import { AuthenticationInfo } from './authentication-info.js'; import { AuthenticationInfo } from './authentication-info.js';
import * as core from 'express-serve-static-core';
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest<
P = core.ParamsDictionary,
ResBody = unknown,
ReqBody = unknown,
ReqQuery = core.Query,
Locals extends Record<string, unknown> = Record<string, unknown>,
> extends Request<P, ResBody, ReqBody, ReqQuery, Locals> {
// Properties are optional since the user is not necessarily authenticated. // Properties are optional since the user is not necessarily authenticated.
jwtPayload?: JwtPayload; jwtPayload?: JwtPayload;
auth?: AuthenticationInfo; auth?: AuthenticationInfo;

View file

@ -0,0 +1,21 @@
import { authorize } from './auth-checks.js';
import { fetchClass } from '../../../services/classes.js';
import { fetchAllGroups } from '../../../services/groups.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment).
* Only allows requests from users who are
* - either teachers of the class the assignment was posted in,
* - or students in a group of the assignment.
*/
export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => {
const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number };
if (auth.accountType === AccountType.Teacher) {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
const groups = await fetchAllGroups(classId, assignmentId);
return groups.some((group) => group.members.map((member) => member.username).includes(auth.username));
});

View file

@ -0,0 +1,61 @@
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import * as express from 'express';
import { RequestHandler } from 'express';
import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../../exceptions/forbidden-exception.js';
import { envVars, getEnvVar } from '../../../util/envVars.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill
* the given access condition.
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
* to true.
*/
export function authorize<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>(
accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean>
): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> {
// Bypass authentication during testing
if (getEnvVar(envVars.RunMode) === 'test') {
return async (
_req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
next();
};
}
return async (
req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
if (!req.auth) {
throw new UnauthorizedException();
} else if (!(await accessCondition(req.auth, req))) {
throw new ForbiddenException();
} else {
next();
}
};
}
/**
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
*/
export const authenticatedOnly = authorize((_) => true);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't students.
*/
export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher);
/**
* Middleware which is to be used on requests no normal user should be able to execute.
* Since there is no concept of administrator accounts yet, currently, those requests will always be blocked.
*/
export const adminOnly = authorize(() => false);

View file

@ -0,0 +1,70 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { fetchClass } from '../../../services/classes.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { getAllInvitations } from '../../../services/teacher-invitations.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
async function teaches(teacherUsername: string, classId: string): Promise<boolean> {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(teacherUsername);
}
/**
* To be used on a request with path parameters username and classId.
* Only allows requests whose username parameter is equal to the username of the user who is logged in and requests
* whose classId parameter references a class the logged-in user is a teacher of.
*/
export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
if (req.params.username === auth.username) {
return true;
} else if (auth.accountType === AccountType.Teacher) {
return teaches(auth.username, req.params.classId);
}
return false;
});
/**
* Only let the request pass through if its path parameter "username" is the username of the currently logged-in
* teacher and the path parameter "classId" refers to a class the teacher teaches.
*/
export const onlyAllowTeacherOfClass = authorize(
async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId)
);
/**
* Only let the request pass through if the class id in it refers to a class the current user is in (as a student
* or teacher)
*/
export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const classId = req.params.classId ?? req.params.classid ?? req.params.id;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});
export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const classId = req.params.classId ?? req.params.classid ?? req.params.id;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
const invitations = await getAllInvitations(auth.username, false);
return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});
/**
* Only allows the request to pass if the 'class' property in its body is a class the current user is a member of.
*/
export const onlyAllowOwnClassInBody = authorize(async (auth, req) => {
const classId = (req.body as { class: string })?.class;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,26 @@
import { authorize } from './auth-checks.js';
import { fetchClass } from '../../../services/classes.js';
import { fetchGroup } from '../../../services/groups.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'.
* Only allows requests from users who are
* - either teachers of the class the assignment for the group was posted in,
* - or students in the group
*/
export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => {
const {
classid: classId,
assignmentid: assignmentId,
groupid: groupId,
} = req.params as { classid: string; assignmentid: number; groupid: number };
if (auth.accountType === AccountType.Teacher) {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(auth.username);
} // User is student
const group = await fetchGroup(classId, assignmentId, groupId);
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,21 @@
import { authorize } from './auth-checks';
import { AuthenticationInfo } from '../authentication-info';
import { AuthenticatedRequest } from '../authenticated-request';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId')
* are
* - either not set
* - or set to a group the user is in,
* - or set to anything if the user is a teacher.
*/
export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { forGroup, assignmentNo, classId } = req.params;
if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) {
// TODO: groupNumber?
// Const group = await fetchGroup(Number(classId), Number(assignmentNo), )
return false;
}
return true;
});

View file

@ -0,0 +1,16 @@
import { Language } from '@dwengo-1/common/util/language';
import learningObjectService from '../../../services/learning-objects/learning-object-service.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid } = req.params;
const { version, language } = req.query;
const admins = await learningObjectService.getAdmins({
hruid,
language: language as Language,
version: parseInt(version as string),
});
return admins.includes(auth.username);
});

View file

@ -0,0 +1,13 @@
import { Language } from '@dwengo-1/common/util/language';
import learningPathService from '../../../services/learning-paths/learning-path-service.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const adminsForLearningPath = await learningPathService.getAdmins({
hruid: req.params.hruid,
language: req.params.language as Language,
});
return adminsForLearningPath && adminsForLearningPath.includes(auth.username);
});

View file

@ -0,0 +1,66 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { requireFields } from '../../../controllers/error-helper.js';
import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js';
import { fetchQuestion } from '../../../services/questions.js';
import { FALLBACK_SEQ_NUM } from '../../../config.js';
import { fetchAnswer } from '../../../services/answers.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
export const onlyAllowAuthor = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username
);
export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const hruid = req.params.hruid;
const version = req.params.version;
const language = req.query.lang as string;
const seq = req.params.seq;
requireFields({ hruid });
const learningObjectId = getLearningObjectId(hruid, version, language);
const questionId = getQuestionId(learningObjectId, seq);
const question = await fetchQuestion(questionId);
return question.author.username === auth.username;
});
export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const hruid = req.params.hruid;
const version = req.params.version;
const language = req.query.lang as string;
const seq = req.params.seq;
const seqAnswer = req.params.seqAnswer;
requireFields({ hruid });
const learningObjectId = getLearningObjectId(hruid, version, language);
const questionId = getQuestionId(learningObjectId, seq);
const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM;
const answer = await fetchAnswer(questionId, sequenceNumber);
return answer.author.username === auth.username;
});
export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const hruid = req.params.hruid;
const version = req.params.version;
const language = req.query.lang as string;
const seq = req.params.seq;
requireFields({ hruid });
const learningObjectId = getLearningObjectId(hruid, version, language);
const questionId = getQuestionId(learningObjectId, seq);
const question = await fetchQuestion(questionId);
const group = question.inGroup;
if (auth.accountType === AccountType.Teacher) {
const cls = group.assignment.within; // TODO check if contains full objects
return cls.teachers.map(mapToUsername).includes(auth.username);
} // User is student
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,28 @@
import { languageMap } from '@dwengo-1/common/util/language';
import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js';
import { fetchSubmission } from '../../../services/submissions.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
import { FALLBACK_LANG } from '../../../config.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
export const onlyAllowSubmitter = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username
);
export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid: lohruid, id: submissionNumber } = req.params;
const { language: lang, version: version } = req.query;
const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version));
const submission = await fetchSubmission(loId, Number(submissionNumber));
if (auth.accountType === AccountType.Teacher) {
// Dit kan niet werken om dat al deze objecten niet gepopulate zijn.
return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username);
}
return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,17 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
export const onlyAllowSenderOrReceiver = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username
);
export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username);
export const onlyAllowSenderBody = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username
);
export const onlyAllowReceiverBody = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username
);

View file

@ -0,0 +1,8 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
/**
* Only allow the user whose username is in the path parameter "username" to access the endpoint.
*/
export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username);

View file

@ -1,16 +1,18 @@
import express from 'express'; import express from 'express';
import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', getAllAnswersHandler); router.get('/', authenticatedOnly, getAllAnswersHandler);
router.post('/', createAnswerHandler); router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler);
router.get('/:seqAnswer', getAnswerHandler); router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler);
router.delete('/:seqAnswer', deleteAnswerHandler); router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler);
router.put('/:seqAnswer', updateAnswerHandler); router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler);
export default router; export default router;

View file

@ -9,22 +9,25 @@ import {
putAssignmentHandler, putAssignmentHandler,
} from '../controllers/assignments.js'; } from '../controllers/assignments.js';
import groupRouter from './groups.js'; import groupRouter from './groups.js';
import { teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js';
import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', getAllAssignmentsHandler); router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler);
router.post('/', createAssignmentHandler); router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler);
router.get('/:id', getAssignmentHandler); router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler);
router.put('/:id', putAssignmentHandler); router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler);
router.delete('/:id', deleteAssignmentHandler); router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler);
router.get('/:id/submissions', getAssignmentsSubmissionsHandler); router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler);
router.get('/:id/questions', getAssignmentQuestionsHandler); router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler);
router.use('/:assignmentid/groups', groupRouter); router.use('/:assignmentid/groups', groupRouter);

View file

@ -1,28 +1,35 @@
import express from 'express'; import express from 'express';
import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Returns auth configuration for frontend // Returns auth configuration for frontend
router.get('/config', (_req, res) => { router.get('/config', handleGetFrontendAuthConfig);
res.json(getFrontendAuthConfig());
});
router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => {
/* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ /* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
res.json({ message: 'If you see this, you should be authenticated!' }); res.json({ message: 'If you see this, you should be authenticated!' });
}); });
router.get('/testStudentsOnly', studentsOnly, (_req, res) => { router.get('/testStudentsOnly', studentsOnly, (_req, res) => {
/* #swagger.security = [{ "student": [ ] }] */ /* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */
res.json({ message: 'If you see this, you should be a student!' }); res.json({ message: 'If you see this, you should be a student!' });
}); });
router.get('/testTeachersOnly', teachersOnly, (_req, res) => { router.get('/testTeachersOnly', teachersOnly, (_req, res) => {
/* #swagger.security = [{ "teacher": [ ] }] */ /* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */
res.json({ message: 'If you see this, you should be a teacher!' }); res.json({ message: 'If you see this, you should be a teacher!' });
}); });
router.post('/hello', authenticatedOnly, postHelloHandler); // This endpoint is called by the client when the user has just logged in.
// It creates or updates the user entity based on the authentication data the endpoint was called with.
router.post(
'/hello',
authenticatedOnly,
/*
#swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }]
*/ postHelloHandler
);
export default router; export default router;

View file

@ -14,33 +14,35 @@ import {
putClassHandler, putClassHandler,
} from '../controllers/classes.js'; } from '../controllers/classes.js';
import assignmentRouter from './assignments.js'; import assignmentRouter from './assignments.js';
import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects router.get('/', adminOnly, getAllClassesHandler);
router.get('/', getAllClassesHandler);
router.post('/', createClassHandler); router.post('/', teachersOnly, createClassHandler);
router.get('/:id', getClassHandler); router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler);
router.put('/:id', putClassHandler); router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler);
router.delete('/:id', deleteClassHandler); router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler);
router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler);
router.get('/:id/students', getClassStudentsHandler); router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler);
router.post('/:id/students', addClassStudentHandler); router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler);
router.delete('/:id/students/:username', deleteClassStudentHandler); router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler);
router.get('/:id/teachers', getClassTeachersHandler); router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler);
router.post('/:id/teachers', addClassTeacherHandler); // De combinatie van deze POST en DELETE endpoints kan lethal zijn
router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler);
router.delete('/:id/teachers/:username', deleteClassTeacherHandler); router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler);
router.use('/:classid/assignments', assignmentRouter); router.use('/:classid/assignments', assignmentRouter);

View file

@ -8,22 +8,24 @@ import {
getGroupSubmissionsHandler, getGroupSubmissionsHandler,
putGroupHandler, putGroupHandler,
} from '../controllers/groups.js'; } from '../controllers/groups.js';
import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js';
import { teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler);
router.get('/', getAllGroupsHandler);
router.post('/', createGroupHandler); router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler);
router.get('/:groupid', getGroupHandler); router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler);
router.put('/:groupid', putGroupHandler); router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler);
router.delete('/:groupid', deleteGroupHandler); router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler);
router.get('/:groupid/submissions', getGroupSubmissionsHandler); router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler);
router.get('/:groupid/questions', getGroupQuestionsHandler); router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler);
export default router; export default router;

View file

@ -1,8 +1,17 @@
import express from 'express'; import express from 'express';
import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; import {
getAllLearningObjects,
getAttachment,
getLearningObject,
getLearningObjectHTML,
handleDeleteLearningObject,
handlePostLearningObject,
} from '../controllers/learning-objects.js';
import submissionRoutes from './submissions.js'; import submissionRoutes from './submissions.js';
import questionRoutes from './questions.js'; import questionRoutes from './questions.js';
import fileUpload from 'express-fileupload';
import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
@ -16,13 +25,21 @@ const router = express.Router();
// Route 2: list of object data // Route 2: list of object data
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
router.get('/', getAllLearningObjects); router.get('/', authenticatedOnly, getAllLearningObjects);
router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject);
// Parameter: hruid of learning object // Parameter: hruid of learning object
// Query: language // Query: language
// Route to fetch data of one learning object based on its hruid // Route to fetch data of one learning object based on its hruid
// Example: http://localhost:3000/learningObject/un_ai7 // Example: http://localhost:3000/learningObject/un_ai7
router.get('/:hruid', getLearningObject); router.get('/:hruid', authenticatedOnly, getLearningObject);
// Parameter: hruid of learning object
// Query: language
// Route to delete a learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1
router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject);
router.use('/:hruid/submissions', submissionRoutes); router.use('/:hruid/submissions', submissionRoutes);
@ -32,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes);
// Query: language, version (optional) // Query: language, version (optional)
// Route to fetch the HTML rendering of one learning object based on its hruid. // Route to fetch the HTML rendering of one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7/html // Example: http://localhost:3000/learningObject/un_ai7/html
router.get('/:hruid/html', getLearningObjectHTML); router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML);
// Parameter: hruid of learning object, name of attachment. // Parameter: hruid of learning object, name of attachment.
// Query: language, version (optional). // Query: language, version (optional).
// Route to get the raw data of the attachment for one learning object based on its hruid. // 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 // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
router.get('/:hruid/html/:attachmentName', getAttachment); router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment);
export default router; export default router;

View file

@ -1,5 +1,7 @@
import express from 'express'; import express from 'express';
import { getLearningPaths } from '../controllers/learning-paths.js'; import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js';
import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js';
const router = express.Router(); const router = express.Router();
@ -22,6 +24,10 @@ const router = express.Router();
// Route to fetch learning paths based on a theme // Route to fetch learning paths based on a theme
// Example: http://localhost:3000/learningPath?theme=kiks // Example: http://localhost:3000/learningPath?theme=kiks
router.get('/', getLearningPaths); router.get('/', authenticatedOnly, getLearningPaths);
router.post('/', teachersOnly, postLearningPath);
router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath);
router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath);
export default router; export default router;

View file

@ -1,20 +1,25 @@
import express from 'express'; import express from 'express';
import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js';
import answerRoutes from './answers.js'; import answerRoutes from './answers.js';
import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
import { updateAnswerHandler } from '../controllers/answers.js';
import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
// Query language // Query language
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllQuestionsHandler); router.get('/', authenticatedOnly, getAllQuestionsHandler);
router.post('/', createQuestionHandler); router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler);
router.delete('/:seq', deleteQuestionHandler);
// Information about a question with id // Information about a question with id
router.get('/:seq', getQuestionHandler); router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler);
router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler);
router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler);
router.use('/:seq/answers', answerRoutes); router.use('/:seq/answers', answerRoutes);

View file

@ -18,12 +18,30 @@ router.get('/', (_, res: Response) => {
}); });
}); });
router.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */);
router.use('/class', classRouter /* #swagger.tags = ['Class'] */);
router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */);
router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); router.use(
router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); '/class',
router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/learningObject',
learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/learningPath',
learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/student',
studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/teacher',
teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/theme',
themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
export default router; export default router;

View file

@ -5,15 +5,19 @@ import {
getStudentRequestHandler, getStudentRequestHandler,
getStudentRequestsHandler, getStudentRequestsHandler,
} from '../controllers/students.js'; } from '../controllers/students.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js';
// Under /:username/joinRequests/
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', getStudentRequestsHandler); router.get('/', preventImpersonation, getStudentRequestsHandler);
router.post('/', createStudentRequestHandler); router.post('/', preventImpersonation, createStudentRequestHandler);
router.get('/:classId', getStudentRequestHandler); router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler);
router.delete('/:classId', deleteClassJoinRequestHandler); router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler);
export default router; export default router;

View file

@ -11,33 +11,37 @@ import {
getStudentSubmissionsHandler, getStudentSubmissionsHandler,
} from '../controllers/students.js'; } from '../controllers/students.js';
import joinRequestRouter from './student-join-requests.js'; import joinRequestRouter from './student-join-requests.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { adminOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllStudentsHandler); router.get('/', adminOnly, getAllStudentsHandler);
router.post('/', createStudentHandler); // Users will be created automatically when some resource is created for them. Therefore, this endpoint
// Can only be used by an administrator.
router.post('/', adminOnly, createStudentHandler);
router.delete('/:username', deleteStudentHandler); router.delete('/:username', preventImpersonation, deleteStudentHandler);
// Information about a student's profile // Information about a student's profile
router.get('/:username', getStudentHandler); router.get('/:username', preventImpersonation, getStudentHandler);
// The list of classes a student is in // The list of classes a student is in
router.get('/:username/classes', getStudentClassesHandler); router.get('/:username/classes', preventImpersonation, getStudentClassesHandler);
// The list of submissions a student has made // The list of submissions a student has made
router.get('/:username/submissions', getStudentSubmissionsHandler); router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler);
// The list of assignments a student has // The list of assignments a student has
router.get('/:username/assignments', getStudentAssignmentsHandler); router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler);
// The list of groups a student is in // The list of groups a student is in
router.get('/:username/groups', getStudentGroupsHandler); router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler);
// A list of questions a user has created // A list of questions a user has created
router.get('/:username/questions', getStudentQuestionsHandler); router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler);
router.use('/:username/joinRequests', joinRequestRouter); router.use('/:username/joinRequests', joinRequestRouter);

View file

@ -1,15 +1,15 @@
import express from 'express'; import express from 'express';
import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js';
import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js';
import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects router.get('/', adminOnly, getSubmissionsHandler);
router.get('/', getSubmissionsHandler);
router.post('/', createSubmissionHandler); router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);
// Information about an submission with id 'id' router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler);
router.get('/:id', getSubmissionHandler);
router.delete('/:id', deleteSubmissionHandler); router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler);
export default router; export default router;

View file

@ -6,17 +6,24 @@ import {
getInvitationHandler, getInvitationHandler,
updateInvitationHandler, updateInvitationHandler,
} from '../controllers/teacher-invitations.js'; } from '../controllers/teacher-invitations.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import {
onlyAllowReceiverBody,
onlyAllowSender,
onlyAllowSenderBody,
onlyAllowSenderOrReceiver,
} from '../middleware/auth/checks/teacher-invitation-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/:username', getAllInvitationsHandler); router.get('/:username', preventImpersonation, getAllInvitationsHandler);
router.get('/:sender/:receiver/:classId', getInvitationHandler); router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler);
router.post('/', createInvitationHandler); router.post('/', onlyAllowSenderBody, createInvitationHandler);
router.put('/', updateInvitationHandler); router.put('/', onlyAllowReceiverBody, updateInvitationHandler);
router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler);
export default router; export default router;

View file

@ -7,34 +7,34 @@ import {
getTeacherAssignmentsHandler, getTeacherAssignmentsHandler,
getTeacherClassHandler, getTeacherClassHandler,
getTeacherHandler, getTeacherHandler,
getTeacherQuestionHandler,
getTeacherStudentHandler, getTeacherStudentHandler,
updateStudentJoinRequestHandler, updateStudentJoinRequestHandler,
} from '../controllers/teachers.js'; } from '../controllers/teachers.js';
import invitationRouter from './teacher-invitations.js'; import invitationRouter from './teacher-invitations.js';
import { adminOnly } from '../middleware/auth/checks/auth-checks.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllTeachersHandler); router.get('/', adminOnly, getAllTeachersHandler);
router.post('/', createTeacherHandler); router.post('/', adminOnly, createTeacherHandler);
router.get('/:username', getTeacherHandler); router.get('/:username', preventImpersonation, getTeacherHandler);
router.delete('/:username', deleteTeacherHandler); router.delete('/:username', preventImpersonation, deleteTeacherHandler);
router.get('/:username/classes', getTeacherClassHandler); router.get('/:username/classes', preventImpersonation, getTeacherClassHandler);
router.get('/:username/students', preventImpersonation, getTeacherStudentHandler);
router.get(`/:username/assignments`, getTeacherAssignmentsHandler); router.get(`/:username/assignments`, getTeacherAssignmentsHandler);
router.get('/:username/students', getTeacherStudentHandler);
router.get('/:username/questions', getTeacherQuestionHandler); router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler);
router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler);
router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler);
// Invitations to other classes a teacher received // Invitations to other classes a teacher received
router.use('/invitations', invitationRouter); router.use('/invitations', invitationRouter);

View file

@ -1,14 +1,15 @@
import express from 'express'; import express from 'express';
import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js';
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Query: language // Query: language
// Route to fetch list of {key, title, description, image} themes in their respective language // Route to fetch list of {key, title, description, image} themes in their respective language
router.get('/', getThemesHandler); router.get('/', authenticatedOnly, getThemesHandler);
// Arg: theme (key) // Arg: theme (key)
// Route to fetch list of hruids based on theme // Route to fetch list of hruids based on theme
router.get('/:theme', getHruidsByThemeHandler); router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler);
export default router; export default router;

View file

@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat
return mapToAnswerDTO(answer); return mapToAnswerDTO(answer);
} }
async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> {
const answerRepository = getAnswerRepository(); const answerRepository = getAnswerRepository();
const question = await fetchQuestion(questionId); const question = await fetchQuestion(questionId);
const answer = await answerRepository.findAnswer(question, sequenceNumber); const answer = await answerRepository.findAnswer(question, sequenceNumber);

View file

@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou
return group; return group;
} }
export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise<Group[]> {
const assignment = await fetchAssignment(classId, assignmentNumber);
const groupRepository = getGroupRepository();
const groups = await groupRepository.findAllGroupsForAssignment(assignment);
return groups;
}
export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> {
const group = await fetchGroup(classId, assignmentNumber, groupNumber); const group = await fetchGroup(classId, assignmentNumber, groupNumber);
return mapToGroupDTO(group, group.assignment.within); return mapToGroupDTO(group, group.assignment.within);

View file

@ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
); );
return learningObjects.filter((it) => it !== null); return learningObjects.filter((it) => it !== null);
}, },
/**
* Returns all learning objects containing the given username as an admin.
*/
async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> {
const learningObjectRepo = getLearningObjectRepository();
const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername);
return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null);
},
}; };
export default databaseLearningObjectProvider; export default databaseLearningObjectProvider;

View file

@ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
return html; return html;
}, },
/**
* Obtain all learning objects who have the user with the given username as an admin.
*/
async getLearningObjectsAdministratedBy(_adminUsername: string): Promise<FilteredLearningObject[]> {
return []; // The dwengo database does not contain any learning objects administrated by users.
},
}; };
export default dwengoApiLearningObjectProvider; export default dwengoApiLearningObjectProvider;

View file

@ -20,4 +20,9 @@ export interface LearningObjectProvider {
* Obtain a HTML-rendering of the learning object with the given identifier (as a string). * Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/ */
getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>; getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>;
/**
* Obtain all learning object who have the user with the given username as an admin.
*/
getLearningObjectsAdministratedBy(username: string): Promise<FilteredLearningObject[]>;
} }

View file

@ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js';
import { envVars, getEnvVar } from '../../util/envVars.js'; import { envVars, getEnvVar } from '../../util/envVars.js';
import databaseLearningObjectProvider from './database-learning-object-provider.js'; import databaseLearningObjectProvider from './database-learning-object-provider.js';
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import { getLearningObjectRepository, getTeacherRepository } from '../../data/repositories.js';
import { processLearningObjectZip } from './learning-object-zip-processing-service.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { NotFoundException } from '../../exceptions/not-found-exception.js';
function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider {
if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
@ -42,6 +47,66 @@ const learningObjectService = {
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> { async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
return getProvider(id).getLearningObjectHTML(id); return getProvider(id).getLearningObjectHTML(id);
}, },
/**
* Obtain all learning objects administrated by the user with the given username.
*/
async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> {
return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername);
},
/**
* Store the learning object in the given zip file in the database.
* @param learningObjectPath The path where the uploaded learning object resides.
* @param admins The usernames of the users which should be administrators of the learning object.
*/
async storeLearningObject(learningObjectPath: string, admins: string[]): Promise<LearningObject> {
const learningObjectRepository = getLearningObjectRepository();
const learningObject = await processLearningObjectZip(learningObjectPath);
if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid;
}
// Lookup the admin teachers based on their usernames and add them to the admins of the learning object.
const teacherRepo = getTeacherRepository();
const adminTeachers = await Promise.all(admins.map(async (it) => teacherRepo.findByUsername(it)));
adminTeachers.forEach((it) => {
if (it !== null) {
learningObject.admins.add(it);
}
});
try {
await learningObjectRepository.save(learningObject, { preventOverwrite: true });
} catch (e: unknown) {
learningObjectRepository.getEntityManager().clear();
throw e;
}
return learningObject;
},
/**
* Deletes the learning object with the given identifier.
*/
async deleteLearningObject(id: LearningObjectIdentifier): Promise<LearningObject | null> {
const learningObjectRepository = getLearningObjectRepository();
return await learningObjectRepository.removeByIdentifier(id);
},
/**
* Returns a list of the usernames of the administrators of the learning object with the given identifier.
* @throws NotFoundException if the specified learning object was not found in the database.
*/
async getAdmins(id: LearningObjectIdentifier): Promise<string[]> {
const learningObjectRepo = getLearningObjectRepository();
const learningObject = await learningObjectRepo.findByIdentifier(id);
if (!learningObject) {
throw new NotFoundException('learningObjectNotFound');
}
return learningObject.admins.map((admin) => admin.username);
},
}; };
export default learningObjectService; export default learningObjectService;

View file

@ -0,0 +1,119 @@
import unzipper from 'unzipper';
import mime from 'mime-types';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { getAttachmentRepository, getLearningObjectRepository } from '../../data/repositories.js';
import { BadRequestException } from '../../exceptions/bad-request-exception.js';
import { LearningObjectMetadata } from '@dwengo-1/common/interfaces/learning-content';
import { DwengoContentType } from './processing/content-type.js';
import { v4 } from 'uuid';
const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/;
const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/;
/**
* Process an uploaded zip file and construct a LearningObject from its contents.
* @param filePath Path of the zip file to process.
*/
export async function processLearningObjectZip(filePath: string): Promise<LearningObject> {
let zip: unzipper.CentralDirectory;
try {
zip = await unzipper.Open.file(filePath);
} catch (_: unknown) {
throw new BadRequestException('invalidZip');
}
let metadata: LearningObjectMetadata | undefined = undefined;
const attachments: { name: string; content: Buffer }[] = [];
let content: Buffer | undefined = undefined;
if (zip.files.length === 0) {
throw new BadRequestException('emptyZip');
}
await Promise.all(
zip.files.map(async (file) => {
if (file.type !== 'Directory') {
if (METADATA_PATH_REGEX.test(file.path)) {
metadata = await processMetadataJson(file);
} else if (CONTENT_PATH_REGEX.test(file.path)) {
content = await processFile(file);
} else {
attachments.push({
name: file.path,
content: await processFile(file),
});
}
}
})
);
if (!metadata) {
throw new BadRequestException('missingMetadata');
}
if (!content) {
throw new BadRequestException('missingIndex');
}
const learningObject = createLearningObject(metadata, content, attachments);
return learningObject;
}
function createLearningObject(metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer }[]): LearningObject {
const learningObjectRepo = getLearningObjectRepository();
const attachmentRepo = getAttachmentRepository();
const returnValue = {
callbackUrl: metadata.return_value?.callback_url ?? '',
callbackSchema: metadata.return_value?.callback_schema ? JSON.stringify(metadata.return_value.callback_schema) : '',
};
if (!metadata.target_ages || metadata.target_ages.length === 0) {
throw new BadRequestException('targetAgesMandatory');
}
const learningObject = learningObjectRepo.create({
admins: [],
available: metadata.available ?? true,
content: content,
contentType: metadata.content_type as DwengoContentType,
copyright: metadata.copyright ?? '',
description: metadata.description ?? '',
educationalGoals: metadata.educational_goals ?? [],
hruid: metadata.hruid,
keywords: metadata.keywords,
language: metadata.language,
license: metadata.license ?? '',
returnValue,
skosConcepts: metadata.skos_concepts ?? [],
teacherExclusive: metadata.teacher_exclusive,
title: metadata.title,
version: metadata.version,
estimatedTime: metadata.estimated_time ?? 1,
targetAges: metadata.target_ages ?? [],
difficulty: metadata.difficulty ?? 1,
uuid: v4(),
});
const attachmentEntities = attachments.map((it) =>
attachmentRepo.create({
name: it.name,
content: it.content,
mimeType: mime.lookup(it.name) || 'text/plain',
learningObject,
})
);
attachmentEntities.forEach((it) => {
learningObject.attachments.add(it);
});
return learningObject;
}
async function processMetadataJson(file: unzipper.File): Promise<LearningObjectMetadata> {
const buf = await file.buffer();
const content = buf.toString();
return JSON.parse(content);
}
async function processFile(file: unzipper.File): Promise<Buffer> {
return await file.buffer();
}

View file

@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js';
import learningObjectService from '../learning-objects/learning-object-service.js'; import learningObjectService from '../learning-objects/learning-object-service.js';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; import { getLastSubmissionForGroup, idFromLearningPathNode, isTransitionPossible } from './learning-path-personalization-util.js';
import { import {
FilteredLearningObject, FilteredLearningObject,
LearningObjectNode, LearningObjectNode,
@ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language';
import { Group } from '../../entities/assignments/group.entity'; import { Group } from '../../entities/assignments/group.entity';
import { Collection } from '@mikro-orm/core'; import { Collection } from '@mikro-orm/core';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { getLogger } from '../../logging/initalize.js';
const logger = getLogger();
/** /**
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
@ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>):
) )
) )
); );
if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) {
throw new Error('At least one of the learning objects on this path could not be found.'); // Ignore all learning objects that cannot be found such that the rest of the learning path keeps working.
for (const [key, value] of nullableNodesToLearningObjects) {
if (value === null) {
logger.warn(`Learning object ${key.learningObjectHruid}/${key.language}/${key.version} not found!`);
nullableNodesToLearningObjects.delete(key);
}
} }
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>; return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
} }
@ -62,6 +70,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
// Convert the learning object notes as retrieved from the database into the expected response format- // Convert the learning object notes as retrieved from the database into the expected response format-
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
const nodesActuallyOnPath = traverseLearningPath(convertedNodes);
return { return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order, __order: order,
@ -71,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
image: image, image: image,
title: learningPath.title, title: learningPath.title,
nodes: convertedNodes, nodes: convertedNodes,
num_nodes: learningPath.nodes.length, num_nodes: nodesActuallyOnPath.length,
num_nodes_left: convertedNodes.filter((it) => !it.done).length, num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length,
keywords: keywords.join(' '), keywords: keywords.join(' '),
target_ages: targetAges, target_ages: targetAges,
max_age: Math.max(...targetAges), max_age: Math.max(...targetAges),
@ -95,14 +105,22 @@ async function convertNode(
personalizedFor: Group | undefined, personalizedFor: Group | undefined,
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): Promise<LearningObjectNode> { ): Promise<LearningObjectNode> {
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningPathNode(node), personalizedFor) : null;
const transitions = node.transitions const transitions = node.transitions
.filter( .filter(
(trans) => (trans) =>
!personalizedFor || // If we do not want a personalized learning path, keep all transitions !personalizedFor || // If we do not want a personalized learning path, keep all transitions
isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible. isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
) )
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)); .map((trans, i) => {
try {
return convertTransition(trans, i, nodesToLearningObjects);
} catch (_: unknown) {
logger.error(`Transition could not be resolved: ${JSON.stringify(trans)}`);
return undefined; // Do not crash on invalid transitions, just ignore them so the rest of the learning path keeps working.
}
})
.filter((it) => it !== undefined);
return { return {
_id: learningObject.uuid, _id: learningObject.uuid,
language: learningObject.language, language: learningObject.language,
@ -174,6 +192,29 @@ function convertTransition(
} }
} }
/**
* Start from the start node and then always take the first transition until there are no transitions anymore.
* Returns the traversed nodes as an array. (This effectively filters outs nodes that cannot be reached.)
*/
function traverseLearningPath(nodes: LearningObjectNode[]): LearningObjectNode[] {
const traversedNodes: LearningObjectNode[] = [];
let currentNode = nodes.find((it) => it.start_node);
while (currentNode) {
traversedNodes.push(currentNode);
const next = currentNode.transitions[0]?.next;
if (next) {
currentNode = nodes.find((it) => it.learningobject_hruid === next.hruid && it.language === next.language && it.version === next.version);
} else {
currentNode = undefined;
}
}
return traversedNodes;
}
/** /**
* Service providing access to data about learning paths from the database. * Service providing access to data about learning paths from the database.
*/ */
@ -198,6 +239,15 @@ const databaseLearningPathProvider: LearningPathProvider = {
}; };
}, },
/**
* Returns all the learning paths which have the user with the given username as an administrator.
*/
async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> {
const repo = getLearningPathRepository();
const paths = await repo.findAllByAdminUsername(adminUsername);
return await Promise.all(paths.map(async (result, index) => convertLearningPath(result, index)));
},
/** /**
* Search learning paths in the database using the given search string. * Search learning paths in the database using the given search string.
*/ */

View file

@ -3,11 +3,33 @@ import { DWENGO_API_BASE } from '../../config.js';
import { LearningPathProvider } from './learning-path-provider.js'; import { LearningPathProvider } from './learning-path-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js'; import { getLogger, Logger } from '../../logging/initalize.js';
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { Group } from '../../entities/assignments/group.entity.js';
import { getLastSubmissionForGroup, idFromLearningObjectNode } from './learning-path-personalization-util.js';
const logger: Logger = getLogger(); const logger: Logger = getLogger();
/**
* Adds progress information to the learning path. Modifies the learning path in-place.
* @param learningPath The learning path to add progress to.
* @param personalizedFor The group whose progress should be shown.
* @returns the modified learning path.
*/
async function addProgressToLearningPath(learningPath: LearningPath, personalizedFor: Group): Promise<LearningPath> {
await Promise.all(
learningPath.nodes.map(async (node) => {
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningObjectNode(node), personalizedFor) : null;
node.done = Boolean(lastSubmission);
})
);
learningPath.num_nodes = learningPath.nodes.length;
learningPath.num_nodes_left = learningPath.nodes.filter((it) => !it.done).length;
return learningPath;
}
const dwengoApiLearningPathProvider: LearningPathProvider = { const dwengoApiLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> { async fetchLearningPaths(hruids: string[], language: string, source: string, personalizedFor: Group): Promise<LearningPathResponse> {
if (hruids.length === 0) { if (hruids.length === 0) {
return { return {
success: false, success: false,
@ -32,19 +54,30 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
}; };
} }
await Promise.all(learningPaths?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
return { return {
success: true, success: true,
source, source,
data: learningPaths, data: learningPaths,
}; };
}, },
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language }; const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
if (searchResults) {
await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
}
return searchResults ?? []; return searchResults ?? [];
}, },
async getLearningPathsAdministratedBy(_adminUsername: string) {
return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
},
}; };
export default dwengoApiLearningPathProvider; export default dwengoApiLearningPathProvider;

View file

@ -5,18 +5,36 @@ import { getSubmissionRepository } from '../../data/repositories.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { JSONPath } from 'jsonpath-plus'; import { JSONPath } from 'jsonpath-plus';
import { LearningObjectNode } from '@dwengo-1/common/interfaces/learning-content';
/** /**
* Returns the last submission for the learning object associated with the given node and for the group * Returns the last submission for the learning object associated with the given node and for the group
*/ */
export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> { export async function getLastSubmissionForGroup(learningObjectId: LearningObjectIdentifier, pathFor: Group): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository(); const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = { return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
}
/**
* Creates a LearningObjectIdentifier describing the specified node.
*/
export function idFromLearningObjectNode(node: LearningObjectNode): LearningObjectIdentifier {
return {
hruid: node.learningobject_hruid,
language: node.language,
version: node.version,
};
}
/**
* Creates a LearningObjectIdentifier describing the specified node.
*/
export function idFromLearningPathNode(node: LearningPathNode): LearningObjectIdentifier {
return {
hruid: node.learningObjectHruid, hruid: node.learningObjectHruid,
language: node.language, language: node.language,
version: node.version, version: node.version,
}; };
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
} }
/** /**

View file

@ -15,4 +15,9 @@ export interface LearningPathProvider {
* Search learning paths in the data source using the given search string. * Search learning paths in the data source using the given search string.
*/ */
searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>;
/**
* Get all learning paths which have the teacher with the given user as an administrator.
*/
getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]>;
} }

View file

@ -1,7 +1,7 @@
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
import databaseLearningPathProvider from './database-learning-path-provider.js'; import databaseLearningPathProvider from './database-learning-path-provider.js';
import { envVars, getEnvVar } from '../../util/envVars.js'; import { envVars, getEnvVar } from '../../util/envVars.js';
import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { Group } from '../../entities/assignments/group.entity.js'; import { Group } from '../../entities/assignments/group.entity.js';
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
@ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { mapToTeacher } from '../../interfaces/teacher.js'; import { mapToTeacher } from '../../interfaces/teacher.js';
import { Collection } from '@mikro-orm/core'; import { Collection } from '@mikro-orm/core';
import { NotFoundException } from '../../exceptions/not-found-exception.js';
import { BadRequestException } from '../../exceptions/bad-request-exception.js';
import learningObjectService from '../learning-objects/learning-object-service.js';
const userContentPrefix = getEnvVar(envVars.UserContentPrefix); const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
@ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L
const fromNode = nodes.find( const fromNode = nodes.find(
(it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version
)!; )!;
const transitions = nodeDto.transitions const transitions = nodeDto.transitions.map((transDto, i) => {
.map((transDto, i) => { const toNode = nodes.find(
const toNode = nodes.find( (it) =>
(it) => it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version
it.learningObjectHruid === transDto.next.hruid && );
it.language === transDto.next.language &&
it.version === transDto.next.version
);
if (toNode) { if (toNode) {
return repo.createTransition({ return repo.createTransition({
transitionNumber: i, transitionNumber: i,
node: fromNode, node: fromNode,
next: toNode, next: toNode,
condition: transDto.condition ?? 'true', condition: transDto.condition ?? 'true',
}); });
} }
return undefined; throw new BadRequestException(
}) `Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.`
.filter((it) => it) );
.map((it) => it!); });
fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions); fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions);
}); });
@ -105,6 +105,14 @@ const learningPathService = {
}; };
}, },
/**
* Fetch the learning paths administrated by the teacher with the given username.
*/
async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> {
const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername)));
return providerResponses.flat();
},
/** /**
* Search learning paths in the data source using the given search string. * Search learning paths in the data source using the given search string.
*/ */
@ -119,11 +127,67 @@ const learningPathService = {
* Add a new learning path to the database. * Add a new learning path to the database.
* @param dto Learning path DTO from which the learning path will be created. * @param dto Learning path DTO from which the learning path will be created.
* @param admins Teachers who should become an admin of the learning path. * @param admins Teachers who should become an admin of the learning path.
* @returns the created learning path.
*/ */
async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> { async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<LearningPathEntity> {
const repo = getLearningPathRepository(); const repo = getLearningPathRepository();
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
if (!dto.hruid.startsWith(userContentPrefix)) {
dto.hruid = userContentPrefix + dto.hruid;
}
const path = mapToLearningPath(dto, admins); const path = mapToLearningPath(dto, admins);
await repo.save(path, { preventOverwrite: true });
// Verify that all specified learning objects actually exist.
const learningObjectsOnPath = await Promise.all(
path.nodes.map(async (node) =>
learningObjectService.getLearningObjectById({
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
})
)
);
if (learningObjectsOnPath.some((it) => !it)) {
throw new BadRequestException('pathContainsNonExistingLearningObjects');
}
try {
await repo.save(path, { preventOverwrite: true });
} catch (e: unknown) {
repo.getEntityManager().clear();
throw e;
}
return path;
},
/**
* Deletes the learning path with the given identifier from the database.
* @param id Identifier of the learning path to delete.
* @returns the deleted learning path.
*/
async deleteLearningPath(id: LearningPathIdentifier): Promise<LearningPathEntity> {
const repo = getLearningPathRepository();
const deletedPath = await repo.deleteByHruidAndLanguage(id.hruid, id.language);
if (deletedPath) {
return deletedPath;
}
throw new NotFoundException('No learning path with the given identifier found.');
},
/**
* Returns a list of the usernames of the administrators of the learning path with the given identifier.
* @param id The identifier of the learning path whose admins should be fetched.
*/
async getAdmins(id: LearningPathIdentifier): Promise<string[]> {
const repo = getLearningPathRepository();
const path = await repo.findByHruidAndLanguage(id.hruid, id.language);
if (!path) {
throw new NotFoundException('No learning path with the given identifier found.');
}
return path.admins.map((admin) => admin.username);
}, },
}; };

View file

@ -13,6 +13,7 @@ import { fetchStudent } from './students.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
import { FALLBACK_VERSION_NUM } from '../config.js'; import { FALLBACK_VERSION_NUM } from '../config.js';
import { fetchAssignment } from './assignments.js'; import { fetchAssignment } from './assignments.js';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getQuestionsAboutLearningObjectInAssignment( export async function getQuestionsAboutLearningObjectInAssignment(
loId: LearningObjectIdentifier, loId: LearningObjectIdentifier,
@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat
const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber);
if (!inGroup) {
throw new NotFoundException('Group with id and assignment not found');
}
if (!inGroup.members.contains(author)) {
throw new ConflictException('Author is not part of this group');
}
const question = await questionRepository.createQuestion({ const question = await questionRepository.createQuestion({
loId, loId,
author, author,
inGroup: inGroup!, inGroup: inGroup,
content, content,
}); });

View file

@ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; import { ConflictException } from '../exceptions/conflict-exception.js';
import { Submission } from '../entities/assignments/submission.entity'; import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise<StudentDTO[] | stri
return users.map(mapToStudentDTO); return users.map(mapToStudentDTO);
} }
return users.map((user) => user.username); return users.map(mapToUsername);
} }
export async function fetchStudent(username: string): Promise<Student> { export async function fetchStudent(username: string): Promise<Student> {
@ -42,7 +43,7 @@ export async function fetchStudent(username: string): Promise<Student> {
const user = await studentRepository.findByUsername(username); const user = await studentRepository.findByUsername(username);
if (!user) { if (!user) {
throw new NotFoundException('Student with username not found'); throw new NotFoundException(`Student with username ${username} not found`);
} }
return user; return user;
@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> {
const newStudent = mapToStudent(userData); const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: true }); await studentRepository.save(newStudent, { preventOverwrite: true });
return userData; return mapToStudentDTO(newStudent);
} }
export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> {

View file

@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise<Tea
throw new ConflictException('The teacher sending the invite is not part of the class'); throw new ConflictException('The teacher sending the invite is not part of the class');
} }
if (cls.teachers.contains(receiver)) {
throw new ConflictException('The teacher receiving the invite is already part of the class');
}
const newInvitation = mapToInvitation(sender, receiver, cls); const newInvitation = mapToInvitation(sender, receiver, cls);
await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true });

View file

@ -2,12 +2,9 @@ import {
getAssignmentRepository, getAssignmentRepository,
getClassJoinRequestRepository, getClassJoinRequestRepository,
getClassRepository, getClassRepository,
getLearningObjectRepository,
getQuestionRepository,
getTeacherRepository, getTeacherRepository,
} from '../data/repositories.js'; } from '../data/repositories.js';
import { mapToClassDTO } from '../interfaces/class.js'; import { mapToClassDTO } from '../interfaces/class.js';
import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js';
import { Teacher } from '../entities/users/teacher.entity.js'; import { Teacher } from '../entities/users/teacher.entity.js';
import { fetchStudent } from './students.js'; import { fetchStudent } from './students.js';
@ -16,10 +13,6 @@ import { mapToStudentRequestDTO } from '../interfaces/student-request.js';
import { TeacherRepository } from '../data/users/teacher-repository.js'; import { TeacherRepository } from '../data/users/teacher-repository.js';
import { ClassRepository } from '../data/classes/class-repository.js'; import { ClassRepository } from '../data/classes/class-repository.js';
import { Class } from '../entities/classes/class.entity.js'; import { Class } from '../entities/classes/class.entity.js';
import { LearningObjectRepository } from '../data/content/learning-object-repository.js';
import { LearningObject } from '../entities/content/learning-object.entity.js';
import { QuestionRepository } from '../data/questions/question-repository.js';
import { Question } from '../entities/questions/question.entity.js';
import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js'; import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js';
import { Student } from '../entities/users/student.entity.js'; import { Student } from '../entities/users/student.entity.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
@ -27,12 +20,12 @@ import { addClassStudent, fetchClass, getClassStudentsDTO } from './classes.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { ClassDTO } from '@dwengo-1/common/interfaces/class'; import { ClassDTO } from '@dwengo-1/common/interfaces/class';
import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; import { ConflictException } from '../exceptions/conflict-exception.js';
import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment';
import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
@ -41,7 +34,7 @@ export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | stri
if (full) { if (full) {
return users.map(mapToTeacherDTO); return users.map(mapToTeacherDTO);
} }
return users.map((user) => user.username); return users.map(mapToUsername);
} }
export async function fetchTeacher(username: string): Promise<Teacher> { export async function fetchTeacher(username: string): Promise<Teacher> {
@ -60,7 +53,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> {
return mapToTeacherDTO(user); return mapToTeacherDTO(user);
} }
export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { // TODO update parameter
export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise<TeacherDTO> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
const newTeacher = mapToTeacher(userData); const newTeacher = mapToTeacher(userData);
@ -124,7 +118,9 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro
const classIds: string[] = classes.map((cls) => cls.id); const classIds: string[] = classes.map((cls) => cls.id);
const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat(); const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId))))
.flat()
.filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index);
if (full) { if (full) {
return students; return students;
@ -133,28 +129,6 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro
return students.map((student) => student.username); return students.map((student) => student.username);
} }
export async function getTeacherQuestions(username: string, full: boolean): Promise<QuestionDTO[] | QuestionId[]> {
const teacher: Teacher = await fetchTeacher(username);
// Find all learning objects that this teacher manages
const learningObjectRepository: LearningObjectRepository = getLearningObjectRepository();
const learningObjects: LearningObject[] = await learningObjectRepository.findAllByTeacher(teacher);
if (!learningObjects || learningObjects.length === 0) {
return [];
}
// Fetch all questions related to these learning objects
const questionRepository: QuestionRepository = getQuestionRepository();
const questions: Question[] = await questionRepository.findAllByLearningObjects(learningObjects);
if (full) {
return questions.map(mapToQuestionDTO);
}
return questions.map(mapToQuestionDTOId);
}
export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> { export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> {
const classRepository: ClassRepository = getClassRepository(); const classRepository: ClassRepository = getClassRepository();
const cls: Class | null = await classRepository.findById(classId); const cls: Class | null = await classRepository.findById(classId);

View file

@ -0,0 +1,76 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { Request, Response } from 'express';
import { getAssignmentHandler, getAllAssignmentsHandler, getAssignmentsSubmissionsHandler } from '../../src/controllers/assignments.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
import { getAssignment01 } from '../test_assets/assignments/assignments.testdata';
function createRequestObject(
classid: string,
assignmentid: string
): {
query: { full: string };
params: { classid: string; id: string };
} {
return {
params: {
classid: classid,
id: assignmentid,
},
query: {
full: 'true',
},
};
}
describe('Assignment controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('return error non-existing assignment', async () => {
req = createRequestObject('doesnotexist', '43000'); // Should not exist
await expect(async () => getAssignmentHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return an assignment', async () => {
const assignment = getAssignment01();
req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString());
await getAssignmentHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignment: expect.anything() }));
});
it('should return a list of assignments', async () => {
req = createRequestObject(getClass01().classId as string, 'irrelevant');
await getAllAssignmentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() }));
});
it('should return a list of submissions for an assignment', async () => {
const assignment = getAssignment01();
req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString());
await getAssignmentsSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
});

View file

@ -0,0 +1,123 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import {
createClassHandler,
deleteClassHandler,
getAllClassesHandler,
getClassHandler,
getClassStudentsHandler,
getTeacherInvitationsHandler,
} from '../../src/controllers/classes.js';
import { Request, Response } from 'express';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
describe('Class controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('create and delete class', async () => {
req = {
body: { displayName: 'coole_nieuwe_klas' },
};
await createClassHandler(req as Request, res as Response);
const result = jsonMock.mock.lastCall?.[0];
// Console.log('class', result.class);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() }));
req = {
params: { id: result.class.id },
};
await deleteClassHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() }));
});
it('Error class not found', async () => {
req = {
params: { id: 'doesnotexist' },
};
await expect(async () => getClassHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('Error create a class without name', async () => {
req = {
body: {},
};
await expect(async () => createClassHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException);
});
it('return list of students', async () => {
req = {
params: { id: getClass01().classId as string },
query: {},
};
await getClassStudentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ students: expect.anything() }));
});
it('Error students on a non-existent class', async () => {
req = {
params: { id: 'doesnotexist' },
query: {},
};
await expect(async () => getClassStudentsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 200 and a list of teacher-invitations', async () => {
const classId = getClass01().classId as string;
req = {
params: { id: classId },
query: {},
};
await getTeacherInvitationsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() }));
});
it('Error teacher-invitations on a non-existent class', async () => {
req = {
params: { id: 'doesnotexist' },
query: {},
};
await expect(async () => getTeacherInvitationsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return a list of classes', async () => {
req = {
query: {},
};
await getAllClassesHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ classes: expect.anything() }));
});
});

View file

@ -0,0 +1,140 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { Request, Response } from 'express';
import {
createGroupHandler,
deleteGroupHandler,
getAllGroupsHandler,
getGroupHandler,
getGroupSubmissionsHandler,
} from '../../src/controllers/groups.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
import { getAssignment01, getAssignment02 } from '../test_assets/assignments/assignments.testdata';
import { getTestGroup01 } from '../test_assets/assignments/groups.testdata';
function createRequestObject(
classid: string,
assignmentid: string,
groupNumber: string
): {
query: { full: string };
params: { classid: string; groupid: string; assignmentid: string };
} {
return {
params: {
classid: classid,
assignmentid: assignmentid,
groupid: groupNumber,
},
query: {
full: 'true',
},
};
}
describe('Group controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('Error not found on a non-existing group', async () => {
req = {
params: {
classid: 'id01',
assignmentid: '1',
groupid: '154981', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 404 not found on a non-existing assignment', async () => {
req = {
params: {
classid: 'id01',
assignmentid: '1000', // Should not exist
groupid: '42000', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 404 not found ont a non-existing class', async () => {
req = {
params: {
classid: 'doesnotexist', // Should not exist
assignmentid: '1000', // Should not exist
groupid: '42000', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return an existing group', async () => {
const group = getTestGroup01();
const classId = getClass01().classId as string;
req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString());
await getGroupHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() }));
});
it('Create and delete', async () => {
const assignment = getAssignment02();
const classId = assignment.within.classId as string;
req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1');
req.body = {
members: ['Noordkaap', 'DireStraits'],
};
await createGroupHandler(req as Request, res as Response);
await deleteGroupHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() }));
});
it('should return the submissions for a group', async () => {
const group = getTestGroup01();
const classId = getClass01().classId as string;
req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString());
await getGroupSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
it('should return a list of groups for an assignment', async () => {
const assignment = getAssignment01();
const classId = assignment.within.classId as string;
req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1');
await getAllGroupsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ groups: expect.anything() }));
});
});

View file

@ -21,6 +21,7 @@ import { BadRequestException } from '../../src/exceptions/bad-request-exception.
import { ConflictException } from '../../src/exceptions/conflict-exception.js'; import { ConflictException } from '../../src/exceptions/conflict-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { getClass02 } from '../test_assets/classes/classes.testdata';
describe('Student controllers', () => { describe('Student controllers', () => {
let req: Partial<Request>; let req: Partial<Request>;
@ -186,7 +187,7 @@ describe('Student controllers', () => {
it('Get join request by student and class', async () => { it('Get join request by student and class', async () => {
req = { req = {
params: { username: 'PinkFloyd', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { username: 'PinkFloyd', classId: getClass02().classId },
}; };
await getStudentRequestHandler(req as Request, res as Response); await getStudentRequestHandler(req as Request, res as Response);
@ -201,7 +202,7 @@ describe('Student controllers', () => {
it('Create and delete join request', async () => { it('Create and delete join request', async () => {
req = { req = {
params: { username: 'TheDoors' }, params: { username: 'TheDoors' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { classId: getClass02().classId },
}; };
await createStudentRequestHandler(req as Request, res as Response); await createStudentRequestHandler(req as Request, res as Response);
@ -209,7 +210,7 @@ describe('Student controllers', () => {
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() })); expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() }));
req = { req = {
params: { username: 'TheDoors', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { username: 'TheDoors', classId: getClass02().classId },
}; };
await deleteClassJoinRequestHandler(req as Request, res as Response); await deleteClassJoinRequestHandler(req as Request, res as Response);
@ -222,7 +223,7 @@ describe('Student controllers', () => {
it('Create join request student already in class error', async () => { it('Create join request student already in class error', async () => {
req = { req = {
params: { username: 'Noordkaap' }, params: { username: 'Noordkaap' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { classId: getClass02().classId },
}; };
await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException);
@ -231,7 +232,7 @@ describe('Student controllers', () => {
it('Create join request duplicate', async () => { it('Create join request duplicate', async () => {
req = { req = {
params: { username: 'Tool' }, params: { username: 'Tool' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { classId: getClass02().classId },
}; };
await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException);

View file

@ -0,0 +1,61 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { getSubmissionHandler, getAllSubmissionsHandler } from '../../src/controllers/submissions.js';
import { Request, Response } from 'express';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass02 } from '../test_assets/classes/classes.testdata';
function createRequestObject(
hruid: string,
submissionNumber: string
): {
query: { language: string; version: string };
params: { hruid: string; id: string };
} {
return {
params: {
hruid: hruid,
id: submissionNumber,
},
query: {
language: 'en',
version: '1',
},
};
}
describe('Submission controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('error submission is not found', async () => {
req = createRequestObject('id01', '1000000');
await expect(async () => getSubmissionHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return a list of submissions for a learning object', async () => {
req = createRequestObject(getClass02().classId as string, 'irrelevant');
await getAllSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
});

View file

@ -12,6 +12,7 @@ import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invit
import { getClassHandler } from '../../src/controllers/classes'; import { getClassHandler } from '../../src/controllers/classes';
import { BadRequestException } from '../../src/exceptions/bad-request-exception'; import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { getClass02 } from '../test_assets/classes/classes.testdata';
describe('Teacher controllers', () => { describe('Teacher controllers', () => {
let req: Partial<Request>; let req: Partial<Request>;
@ -57,7 +58,7 @@ describe('Teacher controllers', () => {
const body = { const body = {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'testleerkracht1', receiver: 'testleerkracht1',
class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', class: getClass02().classId,
} as TeacherInvitationData; } as TeacherInvitationData;
req = { body }; req = { body };
@ -67,7 +68,7 @@ describe('Teacher controllers', () => {
params: { params: {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'testleerkracht1', receiver: 'testleerkracht1',
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: getClass02().classId,
}, },
body: { accepted: 'false' }, body: { accepted: 'false' },
}; };
@ -80,7 +81,7 @@ describe('Teacher controllers', () => {
params: { params: {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'FooFighters', receiver: 'FooFighters',
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: getClass02().classId,
}, },
}; };
await getInvitationHandler(req as Request, res as Response); await getInvitationHandler(req as Request, res as Response);
@ -100,7 +101,7 @@ describe('Teacher controllers', () => {
const body = { const body = {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'FooFighters', receiver: 'FooFighters',
class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', class: getClass02().classId,
} as TeacherInvitationData; } as TeacherInvitationData;
req = { body }; req = { body };
@ -111,7 +112,7 @@ describe('Teacher controllers', () => {
req = { req = {
params: { params: {
id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', id: getClass02().classId,
}, },
}; };

View file

@ -15,8 +15,8 @@ import {
import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { BadRequestException } from '../../src/exceptions/bad-request-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { getStudentRequestsHandler } from '../../src/controllers/students.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { getClassHandler } from '../../src/controllers/classes'; import { getClassHandler } from '../../src/controllers/classes';
import { getClass02 } from '../test_assets/classes/classes.testdata';
describe('Teacher controllers', () => { describe('Teacher controllers', () => {
let req: Partial<Request>; let req: Partial<Request>;
@ -96,7 +96,7 @@ describe('Teacher controllers', () => {
}); });
it('Teacher list', async () => { it('Teacher list', async () => {
req = { query: { full: 'true' } }; req = { query: { full: 'false' } };
await getAllTeachersHandler(req as Request, res as Response); await getAllTeachersHandler(req as Request, res as Response);
@ -104,8 +104,7 @@ describe('Teacher controllers', () => {
const result = jsonMock.mock.lastCall?.[0]; const result = jsonMock.mock.lastCall?.[0];
const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); expect(result.teachers).toContain('testleerkracht1');
expect(teacherUsernames).toContain('testleerkracht1');
expect(result.teachers).toHaveLength(5); expect(result.teachers).toHaveLength(5);
}); });
@ -169,7 +168,7 @@ describe('Teacher controllers', () => {
it('Get join requests by class', async () => { it('Get join requests by class', async () => {
req = { req = {
params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { classId: getClass02().classId },
}; };
await getStudentJoinRequestHandler(req as Request, res as Response); await getStudentJoinRequestHandler(req as Request, res as Response);
@ -183,7 +182,7 @@ describe('Teacher controllers', () => {
it('Update join request status', async () => { it('Update join request status', async () => {
req = { req = {
params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', studentUsername: 'PinkFloyd' }, params: { classId: getClass02().classId, studentUsername: 'PinkFloyd' },
body: { accepted: 'true' }, body: { accepted: 'true' },
}; };
@ -201,7 +200,7 @@ describe('Teacher controllers', () => {
expect(status).toBeTruthy(); expect(status).toBeTruthy();
req = { req = {
params: { id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { id: getClass02().classId },
}; };
await getClassHandler(req as Request, res as Response); await getClassHandler(req as Request, res as Response);

View file

@ -3,6 +3,7 @@ import { setupTestApp } from '../../setup-tests';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass02 } from '../../test_assets/classes/classes.testdata';
describe('AssignmentRepository', () => { describe('AssignmentRepository', () => {
let assignmentRepository: AssignmentRepository; let assignmentRepository: AssignmentRepository;
@ -15,7 +16,7 @@ describe('AssignmentRepository', () => {
}); });
it('should return the requested assignment', async () => { it('should return the requested assignment', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); const assignment = await assignmentRepository.findByClassAndId(class_!, 21001);
expect(assignment).toBeTruthy(); expect(assignment).toBeTruthy();
@ -23,7 +24,7 @@ describe('AssignmentRepository', () => {
}); });
it('should return all assignments for a class', async () => { it('should return all assignments for a class', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!);
expect(assignments).toBeTruthy(); expect(assignments).toBeTruthy();

View file

@ -4,6 +4,7 @@ import { GroupRepository } from '../../../src/data/assignments/group-repository'
import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata';
describe('GroupRepository', () => { describe('GroupRepository', () => {
let groupRepository: GroupRepository; let groupRepository: GroupRepository;
@ -18,7 +19,8 @@ describe('GroupRepository', () => {
}); });
it('should return the requested group', async () => { it('should return the requested group', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const id = getClass01().classId;
const class_ = await classRepository.findById(id);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001);
@ -27,7 +29,7 @@ describe('GroupRepository', () => {
}); });
it('should return all groups for assignment', async () => { it('should return all groups for assignment', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const class_ = await classRepository.findById(getClass01().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const groups = await groupRepository.findAllGroupsForAssignment(assignment!); const groups = await groupRepository.findAllGroupsForAssignment(assignment!);
@ -37,7 +39,7 @@ describe('GroupRepository', () => {
}); });
it('should not find removed group', async () => { it('should not find removed group', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); const assignment = await assignmentRepository.findByClassAndId(class_!, 21001);
await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001); await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001);

View file

@ -18,6 +18,7 @@ import { Submission } from '../../../src/entities/assignments/submission.entity'
import { Class } from '../../../src/entities/classes/class.entity'; import { Class } from '../../../src/entities/classes/class.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata';
import { getClass01 } from '../../test_assets/classes/classes.testdata';
describe('SubmissionRepository', () => { describe('SubmissionRepository', () => {
let submissionRepository: SubmissionRepository; let submissionRepository: SubmissionRepository;
@ -54,7 +55,7 @@ describe('SubmissionRepository', () => {
it('should find the most recent submission for a group', async () => { it('should find the most recent submission for a group', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1); const id = new LearningObjectIdentifier('id03', Language.English, 1);
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const class_ = await classRepository.findById(getClass01().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001);
const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!);
@ -67,7 +68,7 @@ describe('SubmissionRepository', () => {
let assignment: Assignment | null; let assignment: Assignment | null;
let loId: LearningObjectIdentifier; let loId: LearningObjectIdentifier;
it('should find all submissions for a certain learning object and assignment', async () => { it('should find all submissions for a certain learning object and assignment', async () => {
clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); clazz = await classRepository.findById(getClass01().classId);
assignment = await assignmentRepository.findByClassAndId(clazz!, 21000); assignment = await assignmentRepository.findByClassAndId(clazz!, 21000);
loId = { loId = {
hruid: 'id02', hruid: 'id02',

View file

@ -4,6 +4,7 @@ import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join
import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories';
import { StudentRepository } from '../../../src/data/users/student-repository'; import { StudentRepository } from '../../../src/data/users/student-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass02, getClass03 } from '../../test_assets/classes/classes.testdata';
describe('ClassJoinRequestRepository', () => { describe('ClassJoinRequestRepository', () => {
let classJoinRequestRepository: ClassJoinRequestRepository; let classJoinRequestRepository: ClassJoinRequestRepository;
@ -26,7 +27,7 @@ describe('ClassJoinRequestRepository', () => {
}); });
it('should list all requests to a single class', async () => { it('should list all requests to a single class', async () => {
const class_ = await cassRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await cassRepository.findById(getClass02().classId);
const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!);
expect(requests).toBeTruthy(); expect(requests).toBeTruthy();
@ -35,7 +36,7 @@ describe('ClassJoinRequestRepository', () => {
it('should not find a removed request', async () => { it('should not find a removed request', async () => {
const student = await studentRepository.findByUsername('SmashingPumpkins'); const student = await studentRepository.findByUsername('SmashingPumpkins');
const class_ = await cassRepository.findById('80dcc3e0-1811-4091-9361-42c0eee91cfa'); const class_ = await cassRepository.findById(getClass03().classId);
await classJoinRequestRepository.deleteBy(student!, class_!); await classJoinRequestRepository.deleteBy(student!, class_!);
const request = await classJoinRequestRepository.findAllRequestsBy(student!); const request = await classJoinRequestRepository.findAllRequestsBy(student!);

View file

@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { setupTestApp } from '../../setup-tests'; import { setupTestApp } from '../../setup-tests';
import { getClassRepository } from '../../../src/data/repositories'; import { getClassRepository } from '../../../src/data/repositories';
import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata';
describe('ClassRepository', () => { describe('ClassRepository', () => {
let classRepository: ClassRepository; let classRepository: ClassRepository;
@ -18,16 +19,16 @@ describe('ClassRepository', () => {
}); });
it('should return requested class', async () => { it('should return requested class', async () => {
const classVar = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const classVar = await classRepository.findById(getClass01().classId);
expect(classVar).toBeTruthy(); expect(classVar).toBeTruthy();
expect(classVar?.displayName).toBe('class01'); expect(classVar?.displayName).toBe('class01');
}); });
it('class should be gone after deletion', async () => { it('class should be gone after deletion', async () => {
await classRepository.deleteById('33d03536-83b8-4880-9982-9bbf2f908ddf'); await classRepository.deleteById(getClass04().classId);
const classVar = await classRepository.findById('33d03536-83b8-4880-9982-9bbf2f908ddf'); const classVar = await classRepository.findById(getClass04().classId);
expect(classVar).toBeNull(); expect(classVar).toBeNull();
}); });

View file

@ -4,6 +4,7 @@ import { getClassRepository, getTeacherInvitationRepository, getTeacherRepositor
import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository';
import { TeacherRepository } from '../../../src/data/users/teacher-repository'; import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata';
describe('ClassRepository', () => { describe('ClassRepository', () => {
let teacherInvitationRepository: TeacherInvitationRepository; let teacherInvitationRepository: TeacherInvitationRepository;
@ -34,7 +35,7 @@ describe('ClassRepository', () => {
}); });
it('should return all invitations for a class', async () => { it('should return all invitations for a class', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!);
expect(invitations).toBeTruthy(); expect(invitations).toBeTruthy();
@ -42,7 +43,7 @@ describe('ClassRepository', () => {
}); });
it('should not find a removed invitation', async () => { it('should not find a removed invitation', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const class_ = await classRepository.findById(getClass01().classId);
const sender = await teacherRepository.findByUsername('FooFighters'); const sender = await teacherRepository.findByUsername('FooFighters');
const receiver = await teacherRepository.findByUsername('LimpBizkit'); const receiver = await teacherRepository.findByUsername('LimpBizkit');
await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!);

View file

@ -14,6 +14,7 @@ import { Language } from '@dwengo-1/common/util/language';
import { Question } from '../../../src/entities/questions/question.entity'; import { Question } from '../../../src/entities/questions/question.entity';
import { Class } from '../../../src/entities/classes/class.entity'; import { Class } from '../../../src/entities/classes/class.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { getClass01 } from '../../test_assets/classes/classes.testdata';
describe('QuestionRepository', () => { describe('QuestionRepository', () => {
let questionRepository: QuestionRepository; let questionRepository: QuestionRepository;
@ -37,7 +38,7 @@ describe('QuestionRepository', () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1); const id = new LearningObjectIdentifier('id03', Language.English, 1);
const student = await studentRepository.findByUsername('Noordkaap'); const student = await studentRepository.findByUsername('Noordkaap');
const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const clazz = await getClassRepository().findById(getClass01().classId);
const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000);
const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001); const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001);
await questionRepository.createQuestion({ await questionRepository.createQuestion({
@ -56,7 +57,7 @@ describe('QuestionRepository', () => {
let assignment: Assignment | null; let assignment: Assignment | null;
let loId: LearningObjectIdentifier; let loId: LearningObjectIdentifier;
it('should find all questions for a certain learning object and assignment', async () => { it('should find all questions for a certain learning object and assignment', async () => {
clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); clazz = await getClassRepository().findById(getClass01().classId);
assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000);
loId = { loId = {
hruid: 'id05', hruid: 'id05',

View file

@ -6,13 +6,20 @@ import { testLearningPathWithConditions } from '../content/learning-paths.testda
import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata';
export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] { export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 7);
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 7);
const today = new Date();
today.setHours(23, 59);
assignment01 = em.create(Assignment, { assignment01 = em.create(Assignment, {
id: 21000, id: 21000,
within: classes[0], within: classes[0],
title: 'dire straits', title: 'dire straits',
description: 'reading', description: 'reading',
learningPathHruid: 'id02', learningPathHruid: 'un_ai',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: today,
groups: [], groups: [],
}); });
@ -23,6 +30,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'reading', description: 'reading',
learningPathHruid: 'id01', learningPathHruid: 'id01',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: futureDate,
groups: [], groups: [],
}); });
@ -33,6 +41,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'will be deleted', description: 'will be deleted',
learningPathHruid: 'id02', learningPathHruid: 'id02',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: pastDate,
groups: [], groups: [],
}); });
@ -43,6 +52,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'with a description', description: 'with a description',
learningPathHruid: 'id01', learningPathHruid: 'id01',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: pastDate,
groups: [], groups: [],
}); });
@ -53,6 +63,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'You have to do the testing learning path with a condition.', description: 'You have to do the testing learning path with a condition.',
learningPathHruid: testLearningPathWithConditions.hruid, learningPathHruid: testLearningPathWithConditions.hruid,
learningPathLanguage: testLearningPathWithConditions.language as Language, learningPathLanguage: testLearningPathWithConditions.language as Language,
deadline: futureDate,
groups: [], groups: [],
}); });

View file

@ -10,7 +10,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass01: Teacher[] = teachers.slice(4, 5); const teacherClass01: Teacher[] = teachers.slice(4, 5);
class01 = em.create(Class, { class01 = em.create(Class, {
classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', classId: 'X2J9QT', // 8764b861-90a6-42e5-9732-c0d9eb2f55f9
displayName: 'class01', displayName: 'class01',
teachers: teacherClass01, teachers: teacherClass01,
students: studentsClass01, students: studentsClass01,
@ -20,7 +20,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass02: Teacher[] = teachers.slice(1, 2); const teacherClass02: Teacher[] = teachers.slice(1, 2);
class02 = em.create(Class, { class02 = em.create(Class, {
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: '7KLPMA', // 34d484a1-295f-4e9f-bfdc-3e7a23d86a89
displayName: 'class02', displayName: 'class02',
teachers: teacherClass02, teachers: teacherClass02,
students: studentsClass02, students: studentsClass02,
@ -30,7 +30,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass03: Teacher[] = teachers.slice(2, 3); const teacherClass03: Teacher[] = teachers.slice(2, 3);
class03 = em.create(Class, { class03 = em.create(Class, {
classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', classId: 'R0D3UZ', // 80dcc3e0-1811-4091-9361-42c0eee91cfa
displayName: 'class03', displayName: 'class03',
teachers: teacherClass03, teachers: teacherClass03,
students: studentsClass03, students: studentsClass03,
@ -40,14 +40,14 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass04: Teacher[] = teachers.slice(2, 3); const teacherClass04: Teacher[] = teachers.slice(2, 3);
class04 = em.create(Class, { class04 = em.create(Class, {
classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', classId: 'Q8N5YC', // 33d03536-83b8-4880-9982-9bbf2f908ddf
displayName: 'class04', displayName: 'class04',
teachers: teacherClass04, teachers: teacherClass04,
students: studentsClass04, students: studentsClass04,
}); });
classWithTestleerlingAndTestleerkracht = em.create(Class, { classWithTestleerlingAndTestleerkracht = em.create(Class, {
classId: 'a75298b5-18aa-471d-8eeb-5d77eb989393', classId: 'ZAV71B', // Was a75298b5-18aa-471d-8eeb-5d77eb989393
displayName: 'Testklasse', displayName: 'Testklasse',
teachers: [getTestleerkracht1()], teachers: [getTestleerkracht1()],
students: [getTestleerling1()], students: [getTestleerling1()],

View file

@ -7,6 +7,7 @@ export interface AssignmentDTO {
description: string; description: string;
learningPath: string; learningPath: string;
language: string; language: string;
deadline: Date;
groups: GroupDTO[] | string[][]; groups: GroupDTO[] | string[][];
} }

View file

@ -25,8 +25,8 @@ export interface LearningObjectNode {
language: Language; language: Language;
start_node?: boolean; start_node?: boolean;
transitions: Transition[]; transitions: Transition[];
created_at: string; created_at?: string;
updatedAt: string; updatedAt?: string;
done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
} }
@ -79,6 +79,8 @@ export interface LearningObjectMetadata {
target_ages: number[]; target_ages: number[];
content_type: string; // Markdown, image, etc. content_type: string; // Markdown, image, etc.
content_location?: string; content_location?: string;
copyright?: string;
license?: string;
skos_concepts?: string[]; skos_concepts?: string[];
return_value?: ReturnValue; return_value?: ReturnValue;
} }

View file

@ -13,8 +13,8 @@ export interface QuestionDTO {
export interface QuestionData { export interface QuestionData {
author?: string; author?: string;
content: string;
inGroup: GroupDTO; inGroup: GroupDTO;
content: string;
} }
export interface QuestionId { export interface QuestionId {

View file

@ -0,0 +1,4 @@
export enum AccountType {
Student = 'student',
Teacher = 'teacher',
}

View file

@ -67,8 +67,6 @@ services:
- 'traefik.enable=true' - 'traefik.enable=true'
- 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)'
- 'traefik.http.services.idp.loadbalancer.server.port=7080' - 'traefik.http.services.idp.loadbalancer.server.port=7080'
- 'traefik.http.routers.block-admin.rule=PathPrefix(`/idp/admin`)'
- 'traefik.http.routers.block-admin.service=web'
depends_on: depends_on:
- keycloak-db - keycloak-db
volumes: volumes:
@ -95,6 +93,9 @@ services:
- '80:80/tcp' - '80:80/tcp'
- '443:443/tcp' - '443:443/tcp'
command: command:
# Enable web UI
- '--api=true'
# Add Docker provider # Add Docker provider
- '--providers.docker=true' - '--providers.docker=true'
- '--providers.docker.exposedbydefault=false' - '--providers.docker.exposedbydefault=false'
@ -115,6 +116,17 @@ services:
- '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web'
- '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be'
- '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json'
labels:
# BasicAuth middleware
# To create a user:password pair, the following command can be used:
# echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g
- 'traefik.http.middlewares.protected-sub-path.basicauth.users=dwengo.org:$$apr1$$FdALqAjI$$7ZhPq0I/qEQ6k3OYqxJKZ1'
# Proxying
- 'traefik.enable=true'
- 'traefik.http.routers.proxy.middlewares=protected-sub-path'
- 'traefik.http.routers.proxy.service=api@internal'
- 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)'
- 'traefik.http.services.proxy.loadbalancer.server.port=8080'
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
@ -137,8 +149,10 @@ services:
dashboards: dashboards:
image: grafana/grafana:latest image: grafana/grafana:latest
ports: labels:
- '9002:3000' - 'traefik.enable=true'
- 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)'
- 'traefik.http.services.graphs.loadbalancer.server.port=3000'
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- dwengo_grafana_data:/var/lib/grafana - dwengo_grafana_data:/var/lib/grafana

View file

@ -60,6 +60,13 @@ services:
# Add web entrypoint # Add web entrypoint
- '--entrypoints.web.address=:80/tcp' - '--entrypoints.web.address=:80/tcp'
# Proxying the web UI on a sub-path
- '--api.basePath=/proxy'
labels:
- 'traefik.http.routers.proxy.service=api@internal'
- 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)'
- 'traefik.http.services.proxy.loadbalancer.server.port=8080'
ports: ports:
- '9000:8080' - '9000:8080'
- '80:80/tcp' - '80:80/tcp'
@ -82,8 +89,12 @@ services:
image: grafana/grafana:latest image: grafana/grafana:latest
ports: ports:
- '9002:3000' - '9002:3000'
labels:
- 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)'
- 'traefik.http.services.graphs.loadbalancer.server.port=3000'
volumes: volumes:
- dwengo_grafana_data:/var/lib/grafana - dwengo_grafana_data:/var/lib/grafana
- ./config/grafana/grafana.ini:/etc/grafana/grafana.ini
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View file

@ -0,0 +1,8 @@
[server]
root_url = http://localhost:3000/graphs
serve_from_sub_path = true
[security]
admin_user = dwengo.org

View file

@ -26,7 +26,59 @@ const doc = {
], ],
components: { components: {
securitySchemes: { securitySchemes: {
student: { studentDev: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
teacherDev: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
studentStaging: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost/idp/realms/student/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
teacherStaging: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost/idp/realms/teacher/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
studentProduction: {
type: 'oauth2', type: 'oauth2',
flows: { flows: {
implicit: { implicit: {
@ -39,7 +91,7 @@ const doc = {
}, },
}, },
}, },
teacher: { teacherProduction: {
type: 'oauth2', type: 'oauth2',
flows: { flows: {
implicit: { implicit: {

View file

@ -3,22 +3,28 @@ FROM node:22 AS build-stage
# install simple http server for serving static content # install simple http server for serving static content
RUN npm install -g http-server RUN npm install -g http-server
WORKDIR /app WORKDIR /app/dwengo
# Install dependencies # Install dependencies
COPY package*.json ./ COPY package*.json ./
COPY ./frontend/package.json ./frontend/ COPY ./frontend/package.json ./frontend/
# Frontend depends on common
COPY common/package.json ./common/
RUN npm install --silent RUN npm install --silent
# Build the frontend # Build the frontend
# Root tsconfig.json # Root tsconfig.json
COPY tsconfig.json ./ COPY tsconfig.json tsconfig.build.json ./
COPY assets ./assets/
WORKDIR /app/frontend COPY assets ./assets
COPY common ./common
RUN npm run build --workspace=common
WORKDIR /app/dwengo/frontend
COPY frontend ./ COPY frontend ./
@ -28,8 +34,8 @@ FROM nginx:stable AS production-stage
COPY config/nginx/nginx.conf /etc/nginx/nginx.conf COPY config/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=build-stage /app/assets /usr/share/nginx/html/assets COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets
COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html
EXPOSE 8080 EXPOSE 8080

View file

@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test";
test("Teacher can create new assignment", async ({ page }) => {
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "teacher" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Go to assignments
await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click();
await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible();
await expect(page.getByRole("button", { name: "New Assignment" })).toBeVisible();
// Create new assignment
await page.getByRole("button", { name: "New Assignment" }).click();
await expect(page.getByRole("button", { name: "submit" })).toBeVisible();
await expect(page.getByRole("link", { name: "cancel" })).toBeVisible();
await page.getByRole("textbox", { name: "Title Title" }).fill("Assignment test 1");
await page.getByRole("textbox", { name: "Select a learning path Select" }).click();
await page.getByText("Using notebooks").click();
await page.getByRole("textbox", { name: "Pick a class Pick a class" }).click();
await page.getByText("class01").click();
await page.getByRole("textbox", { name: "Select Deadline Select" }).fill("2099-01-01T12:34");
await page.getByRole("textbox", { name: "Description Description" }).fill("Assignment description");
await page.getByRole("button", { name: "submit" }).click();
await expect(page.getByText("Assignment test")).toBeVisible();
await expect(page.getByRole("main").getByRole("button").first()).toBeVisible();
await expect(page.getByRole("main")).toContainText("Assignment test 1");
await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible();
await expect(page.getByRole("main")).toContainText("Assignment description");
});
test("Student can see list of assignments", async ({ page }) => {
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "student" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Go to assignments
await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click();
await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible();
await expect(page.getByText("dire straits")).toBeVisible();
await expect(page.locator(".button-row > .v-btn").first()).toBeVisible();
await expect(page.getByText("Class: class01").first()).toBeVisible();
});
test("Student can see assignment details", async ({ page }) => {
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "student" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Go to assignments
await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click();
await expect(page.getByText("Assignment: Conditional")).toBeVisible();
await expect(page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn")).toBeVisible();
// View assignment details
await page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn").click();
await expect(page.getByText("Assignment: Conditional")).toBeVisible();
await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible();
await expect(page.getByRole("progressbar").locator("div").first()).toBeVisible();
});

View file

@ -1,8 +1,16 @@
import { test, expect } from "./fixtures.js"; import { test, expect } from "@playwright/test";
test("Users can filter", async ({ page }) => { test("Users can filter", async ({ page }) => {
await page.goto("/user"); await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "teacher" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Filter
await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click(); await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click();
await page.getByText("Nature and climate").click(); await page.getByText("Nature and climate").click();
await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click();

View file

@ -1,5 +0,0 @@
import { test, expect } from "./fixtures.js";
test("myTest", async ({ page }) => {
await expect(page).toHaveURL("/");
});

107
frontend/e2e/class.spec.ts Normal file
View file

@ -0,0 +1,107 @@
import { test, expect } from "@playwright/test";
test("Teacher can create a class", async ({ page }) => {
const className = "DeTijdLoze";
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "teacher" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Go to class
await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Classes" }).click();
// Check if the class page is visible
await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "classname classname" })).toBeVisible();
await expect(page.getByRole("button", { name: "create" })).toBeVisible();
// Create a class
await page.getByRole("textbox", { name: "classname classname" }).click();
await page.getByRole("textbox", { name: "classname classname" }).fill(className);
await page.getByRole("button", { name: "create" }).click();
// Check if the class is created
await expect(page.getByRole("dialog").getByText("code")).toBeVisible();
await expect(page.getByRole("button", { name: "close" })).toBeVisible();
});
test("Teacher can share a class by code", async ({ page }) => {
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "teacher" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Go to classes
await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Classes" }).click();
await expect(page.getByRole("row", { name: "class01" }).locator("i").nth(1)).toBeVisible();
await page.getByRole("row", { name: "class01" }).locator("i").nth(1).click();
await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(2)).toBeVisible();
await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(3)).toBeVisible();
await page.getByRole("button").filter({ hasText: /^$/ }).nth(3).click();
await expect(page.getByText("copied!")).toBeVisible();
await page.getByRole("button", { name: "close" }).click();
});
test("Student can join class by code", async ({ page }) => {
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "student" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
// Go to class
await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Classes" }).click();
// Check if the class page is visible
await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Join class" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "CODE CODE" })).toBeVisible();
await expect(page.getByRole("button", { name: "submit" })).toBeVisible();
// Join a class
await page.getByRole("textbox", { name: "CODE CODE" }).click();
await page.getByRole("textbox", { name: "CODE CODE" }).fill("X2J9QT");
await page.getByRole("button", { name: "submit" }).click();
});
test("Teacher can remove student from class", async ({ page }) => {
await page.goto("/");
// Login
await page.getByRole("link", { name: "log in" }).click();
await page.getByRole("button", { name: "teacher" }).click();
await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1");
await page.getByRole("textbox", { name: "Password" }).fill("password");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible();
await page.getByRole("banner").getByRole("link", { name: "Classes" }).click();
await expect(page.getByRole("link", { name: "class01" })).toBeVisible();
await expect(page.locator("#app")).toContainText("8");
await page.getByRole("link", { name: "class01" }).click();
await expect(page.getByRole("cell", { name: "Kurt Cobain" })).toBeVisible();
await expect(page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button")).toBeVisible();
await page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button").click();
await expect(page.getByText("Are you sure?")).toBeVisible();
await expect(page.getByRole("button", { name: "cancel" })).toBeVisible();
await expect(page.getByRole("button", { name: "yes" })).toBeVisible();
await page.getByRole("button", { name: "yes" }).click();
await page.getByRole("banner").getByRole("link", { name: "Classes" }).click();
await expect(page.locator("#app")).toContainText("7");
});

View file

@ -17,18 +17,18 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.8.2", "axios": "^1.8.2",
"interactjs": "^1.10.27", "json-editor-vue": "^0.18.1",
"oidc-client-ts": "^3.1.0", "oidc-client-ts": "^3.1.0",
"rollup": "^4.40.0", "rollup": "^4.40.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^11.1.2", "vue-i18n": "^11.1.2",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vuedraggable": "^2.24.3",
"vuetify": "^3.7.12", "vuetify": "^3.7.12",
"wait-on": "^8.0.3" "wait-on": "^8.0.3"
}, },

View file

@ -0,0 +1,54 @@
.h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
font-size: 50px;
padding-left: 1%;
}
.empty-message {
text-align: center;
font-size: 18px;
}
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
.table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
.table td,
.table th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
@media screen and (max-width: 850px) {
.h1 {
text-align: center;
padding-left: 0;
}
}

View file

@ -5,6 +5,7 @@
import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts";
import { useThemeQuery } from "@/queries/themes.ts"; import { useThemeQuery } from "@/queries/themes.ts";
import type { Theme } from "@/data-objects/theme.ts"; import type { Theme } from "@/data-objects/theme.ts";
import authService from "@/services/auth/auth-service";
const props = defineProps({ const props = defineProps({
selectedTheme: { type: String, required: true }, selectedTheme: { type: String, required: true },
@ -33,6 +34,8 @@
cards.value = themes; cards.value = themes;
} }
}); });
const isTeacher = computed(() => authService.authState.activeRole === "teacher");
</script> </script>
<template> <template>
@ -57,6 +60,39 @@
</div> </div>
<v-row v-else> <v-row v-else>
<v-col
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/learningPath/search"
:is-absolute-path="true"
:title="t('searchAllLearningPathsTitle')"
:description="t('searchAllLearningPathsDescription')"
icon="mdi-magnify"
class="fill-height grey-bg-card"
/>
</v-col>
<v-col
v-if="isTeacher"
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/my-content"
:is-absolute-path="true"
:title="t('ownLearningContentTitle')"
:description="t('ownLearningContentDescription')"
icon="mdi-pencil"
class="fill-height grey-bg-card"
/>
</v-col>
<v-col <v-col
v-for="card in cards" v-for="card in cards"
:key="card.key" :key="card.key"
@ -74,24 +110,13 @@
class="fill-height" class="fill-height"
/> />
</v-col> </v-col>
<v-col
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/learningPath/search"
:is-absolute-path="true"
:title="t('searchAllLearningPathsTitle')"
:description="t('searchAllLearningPathsDescription')"
icon="mdi-magnify"
class="fill-height"
/>
</v-col>
</v-row> </v-row>
</v-container> </v-container>
</template> </template>
<style scoped></style> <style scoped>
.grey-bg-card {
background-color: #f6faf2;
border: 2px solid #0e6942;
}
</style>

View file

@ -0,0 +1,63 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const props = defineProps<{
text: string;
prependIcon?: string;
appendIcon?: string;
confirmQueryText: string;
variant?: "flat" | "text" | "elevated" | "tonal" | "outlined" | "plain" | undefined;
color?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{ (e: "confirm"): void }>();
const { t } = useI18n();
function confirm(): void {
emit("confirm");
}
</script>
<template>
<v-dialog max-width="500">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
:text="props.text"
:prependIcon="props.prependIcon"
:appendIcon="props.appendIcon"
:variant="props.variant"
:color="color"
:disabled="props.disabled"
></v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card :title="t('confirmDialogTitle')">
<v-card-text>
{{ props.confirmQueryText }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:text="t('yes')"
@click="
confirm();
isActive.value = false;
"
></v-btn>
<v-btn
:text="t('cancel')"
@click="isActive.value = false"
></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
<style scoped></style>

View file

@ -31,4 +31,9 @@
></v-text-field> ></v-text-field>
</template> </template>
<style scoped></style> <style scoped>
.search-field {
width: 25%;
min-width: 300px;
}
</style>

View file

@ -53,9 +53,9 @@
white-space: normal; white-space: normal;
} }
.results-grid { .results-grid {
margin: 20px; margin: 20px auto;
display: flex; display: flex;
align-items: stretch; justify-content: center;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }

View file

@ -7,13 +7,17 @@
// Import assets // Import assets
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
import { useLocale } from "vuetify";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { current: vuetifyLocale } = useLocale();
const role = auth.authState.activeRole; const role = auth.authState.activeRole;
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
const name: string = auth.authState.user!.profile.name!; const name: string = auth.authState.user!.profile.name!;
const username = auth.authState.user!.profile.preferred_username!;
const email = auth.authState.user!.profile.email;
const initials: string = name const initials: string = name
.split(" ") .split(" ")
.map((n) => n[0]) .map((n) => n[0])
@ -30,6 +34,7 @@
// Logic to change the language of the website to the selected language // Logic to change the language of the website to the selected language
function changeLanguage(langCode: string): void { function changeLanguage(langCode: string): void {
locale.value = langCode; locale.value = langCode;
vuetifyLocale.value = langCode;
localStorage.setItem("user-lang", langCode); localStorage.setItem("user-lang", langCode);
} }
@ -90,31 +95,34 @@
<!-- >--> <!-- >-->
<!-- {{ t("discussions") }}--> <!-- {{ t("discussions") }}-->
<!-- </v-btn>--> <!-- </v-btn>-->
<v-menu open-on-hover>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar-items> </v-toolbar-items>
<v-menu
open-on-hover
open-on-click
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-dialog max-width="500"> <v-dialog max-width="500">
<template v-slot:activator="{ props: activatorProps }"> <template v-slot:activator="{ props: activatorProps }">
@ -158,12 +166,48 @@
</v-card> </v-card>
</template> </template>
</v-dialog> </v-dialog>
<v-avatar <v-menu min-width="200px">
size="large" <template v-slot:activator="{ props }">
color="#0e6942" <v-btn
class="user-button" icon
>{{ initials }}</v-avatar v-bind="props"
> >
<v-avatar
color="#0e6942"
size="large"
class="user-button"
>
<span>{{ initials }}</span>
</v-avatar>
</v-btn>
</template>
<v-card>
<v-card-text>
<div class="mx-auto text-center">
<v-avatar
color="#0e6942"
size="large"
class="user-button mb-3"
>
<span>{{ initials }}</span>
</v-avatar>
<h3>{{ name }}</h3>
<p class="text-caption mt-1">{{ username }}</p>
<p class="text-caption mt-1">{{ email }}</p>
<v-divider class="my-3"></v-divider>
<v-btn
variant="text"
rounded
append-icon="mdi-logout"
@click="performLogout"
to="/login"
>{{ t("logout") }}</v-btn
>
<v-divider class="my-3"></v-divider>
</div>
</v-card-text>
</v-card>
</v-menu>
</v-app-bar> </v-app-bar>
<v-navigation-drawer <v-navigation-drawer
v-model="drawer" v-model="drawer"
@ -248,6 +292,12 @@
text-transform: none; text-transform: none;
} }
.translate-button {
z-index: 1;
position: relative;
margin-left: 10px;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.menu { .menu {
display: none; display: none;

Some files were not shown because too many files have changed in this diff Show more