diff --git a/backend/.env.test b/backend/.env.test index fb94aa09..2d928db0 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -8,6 +8,7 @@ ### Dwengo ### DWENGO_PORT=3000 +DWENGO_RUN_MODE=test DWENGO_DB_NAME=":memory:" DWENGO_DB_UPDATE=true diff --git a/backend/package.json b/backend/package.json index 7943d61d..9816d7df 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,7 @@ "cross-env": "^7.0.3", "dotenv": "^16.4.7", "express": "^5.0.1", + "express-fileupload": "^1.5.1", "express-jwt": "^8.5.1", "gift-pegjs": "^1.0.2", "isomorphic-dompurify": "^2.22.0", @@ -37,8 +38,11 @@ "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", + "mime-types": "^3.0.1", + "nanoid": "^5.1.5", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", + "unzipper": "^0.12.3", "uuid": "^11.1.0", "winston": "^3.17.0", "winston-loki": "^6.1.3" @@ -47,10 +51,13 @@ "@mikro-orm/cli": "6.4.12", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/express-fileupload": "^1.5.1", "@types/js-yaml": "^4.0.9", + "@types/mime-types": "^2.1.4", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", "@types/swagger-ui-express": "^4.1.8", + "@types/unzipper": "^0.10.11", "globals": "^15.15.0", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 49e2159b..0a249c5b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -1,10 +1,11 @@ import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; import { getLogger } from '../logging/initalize.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 { 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 { 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 { const auth = req.auth; if (!auth) { @@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response) firstName: auth.firstName ?? '', lastName: auth.lastName ?? '', }; - if (auth.accountType === 'student') { + if (auth.accountType === AccountType.Student) { await createOrUpdateStudent(userData); logger.debug(`Synchronized student ${userData.username} with IDP`); } else { diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 83aa33f9..e4318efa 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -7,6 +7,9 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { envVars, getEnvVar } from '../util/envVars.js'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { UploadedFile } from 'express-fileupload'; +import { AuthenticatedRequest } from '../middleware/auth/authenticated-request'; +import { requireFields } from './error-helper.js'; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { if (!req.params.hruid) { @@ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde } function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { - if (!req.query.hruid) { - throw new BadRequestException('HRUID is required.'); - } + const { hruid, language } = req.params; + requireFields({ hruid }); + return { - hruid: req.params.hruid, - language: (req.query.language as Language) || FALLBACK_LANG, + hruid, + language: (language as Language) || FALLBACK_LANG, }; } export async function getAllLearningObjects(req: Request, res: Response): Promise { - const learningPathId = getLearningPathIdentifierFromRequest(req); - const full = req.query.full; + if (req.query.admin) { + // If the admin query parameter is present, the user wants to have all learning objects with this admin. + const learningObjects = await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string); - let learningObjects: FilteredLearningObject[] | string[]; - if (full) { - learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); + res.json(learningObjects); } 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 { @@ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise } res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } + +export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise { + 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 { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + if (!learningObjectId.version) { + throw new BadRequestException('When deleting a learning object, a version must be specified.'); + } + + const deletedLearningObject = await learningObjectService.deleteLearningObject({ + hruid: learningObjectId.hruid, + version: learningObjectId.version, + language: learningObjectId.language, + }); + if (deletedLearningObject) { + res.json(deletedLearningObject); + } else { + throw new NotFoundException('Learning object not found'); + } +} diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 1bd3f2b1..7fdefd2d 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -7,51 +7,103 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { Group } from '../entities/assignments/group.entity.js'; import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; +import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; +import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { getTeacher } from '../services/teachers.js'; +import { requireFields } from './error-helper.js'; /** * Fetch learning paths based on query parameters. */ export async function getLearningPaths(req: Request, res: Response): Promise { - const hruids = req.query.hruid; - const themeKey = req.query.theme as string; - const searchQuery = req.query.search as string; - const language = (req.query.language as string) || FALLBACK_LANG; - - const forGroupNo = req.query.forGroup as string; - const assignmentNo = req.query.assignmentNo as string; - const classId = req.query.classId as string; - - let forGroup: Group | undefined; - - if (forGroupNo) { - if (!assignmentNo || !classId) { - throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); - } - const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); - if (assignment) { - forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; - } - } - - let hruidList; - - if (hruids) { - hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; - } else if (themeKey) { - const theme = themes.find((t) => t.title === themeKey); - if (theme) { - hruidList = theme.hruids; - } else { - throw new NotFoundException(`Theme "${themeKey}" not found.`); - } - } else if (searchQuery) { - const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); - res.json(searchResults); - return; + const admin = req.query.admin; + if (admin) { + const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); + res.json(paths); } else { - hruidList = themes.flatMap((theme) => theme.hruids); - } + const hruids = req.query.hruid; + const themeKey = req.query.theme as string; + const searchQuery = req.query.search as string; + const language = (req.query.language as string) || FALLBACK_LANG; - const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); - res.json(learningPaths.data); + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; + + let forGroup: Group | undefined; + + if (forGroupNo) { + if (!assignmentNo || !classId) { + throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); + } + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); + if (assignment) { + forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; + } + } + + let hruidList; + + if (hruids) { + hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; + } else if (themeKey) { + const theme = themes.find((t) => t.title === themeKey); + if (theme) { + hruidList = theme.hruids; + } else { + throw new NotFoundException(`Theme "${themeKey}" not found.`); + } + } else if (searchQuery) { + const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); + res.json(searchResults); + return; + } else { + hruidList = themes.flatMap((theme) => theme.hruids); + } + + const learningPaths = await learningPathService.fetchLearningPaths( + hruidList, + language as Language, + `HRUIDs: ${hruidList.join(', ')}`, + forGroup + ); + res.json(learningPaths.data); + } +} + +function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise { + return async (req, res) => { + const path = 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 { + 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.'); + } } diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts index 229cff7e..e4c49683 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response): const classId = req.body.classId; requireFields({ username, classId }); - const request = await createClassJoinRequest(username, classId); + const request = await createClassJoinRequest(username, classId.toUpperCase()); res.json({ request }); } diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts index a117d7bf..012636ea 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -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 export async function createSubmissionHandler(req: Request, res: Response): Promise { + 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 submission = await createSubmission(submissionDTO); diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts index 932bb1af..9e8eee6e 100644 --- a/backend/src/controllers/teacher-invitations.ts +++ b/backend/src/controllers/teacher-invitations.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { requireFields } from './error-helper.js'; import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; 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 { const username = req.params.username; @@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom const classId = req.body.class; requireFields({ sender, receiver, classId }); + if (sender === receiver) { + throw new ConflictException('Cannot send an invitation to yourself'); + } + const data = req.body as TeacherInvitationData; const invitation = await createInvitation(data); diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts index 1ac4888e..e7b0d46c 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -81,16 +81,6 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro res.json({ students }); } -export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { - 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 { const classId = req.params.classId; requireFields({ classId }); diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index 889a1594..e793b991 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Language } from '@dwengo-1/common/util/language'; -import { Teacher } from '../../entities/users/teacher.entity.js'; export class LearningObjectRepository extends DwengoEntityRepository { public async findByIdentifier(identifier: LearningObjectIdentifier): Promise { @@ -13,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository { + public async findAllByAdmin(adminUsername: string): Promise { return this.find( - { admins: teacher }, + { + admins: { + username: adminUsername, + }, + }, { populate: ['admins'] } // Make sure to load admin relations ); } + + public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise { + const learningObject = await this.findByIdentifier(identifier); + if (learningObject) { + await this.em.removeAndFlush(learningObject); + } + return learningObject; + } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 67f08a03..238a7676 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -4,11 +4,10 @@ import { Language } from '@dwengo-1/common/util/language'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { RequiredEntityData } from '@mikro-orm/core'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; -import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; export class LearningPathRepository extends DwengoEntityRepository { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { - 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 language: language, $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 { + return this.findAll({ + where: { + admins: { + username: adminUsername, + }, + }, + populate: ['nodes', 'nodes.transitions', 'admins'], }); } @@ -36,18 +49,15 @@ export class LearningPathRepository extends DwengoEntityRepository return this.em.create(LearningPathTransition, transitionData); } - public async saveLearningPathNodesAndTransitions( - path: LearningPath, - nodes: LearningPathNode[], - transitions: LearningPathTransition[], - options?: { preventOverwrite?: boolean } - ): Promise { - if (options?.preventOverwrite && (await this.findOne(path))) { - throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); + /** + * Deletes the learning path with the given hruid and language. + * @returns the deleted learning path or null if it was not found. + */ + public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise { + const path = await this.findByHruidAndLanguage(hruid, language); + if (path) { + await this.em.removeAndFlush(path); } - const em = this.getEntityManager(); - await em.persistAndFlush(path); - await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); - await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); + return path; } } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index b9935b16..f681eebb 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.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 { Loaded } from '@mikro-orm/core'; -import { Group } from '../../entities/assignments/group.entity'; export class QuestionRepository extends DwengoEntityRepository { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index a12ffbac..88c3160f 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -26,6 +26,9 @@ export class Assignment { @Property({ type: 'string' }) learningPathHruid!: string; + @Property({ type: 'datetime', nullable: true }) + deadline?: Date; + @Enum({ items: () => Language, }) diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index b2c59ade..5bedf560 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -1,15 +1,17 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; -import { v4 } from 'uuid'; import { Teacher } from '../users/teacher.entity.js'; import { Student } from '../users/student.entity.js'; import { ClassRepository } from '../../data/classes/class-repository.js'; +import { customAlphabet } from 'nanoid'; + +const generateClassId = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6); @Entity({ repository: () => ClassRepository, }) export class Class { @PrimaryKey() - classId? = v4(); + classId? = generateClassId(); @Property({ type: 'string' }) displayName!: string; diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts index 80104f28..99901495 100644 --- a/backend/src/entities/content/attachment.entity.ts +++ b/backend/src/entities/content/attachment.entity.ts @@ -9,6 +9,7 @@ export class Attachment { @ManyToOne({ entity: () => LearningObject, primary: true, + deleteRule: 'cascade', }) learningObject!: LearningObject; diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index e0ae09d6..1d129b9c 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -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 { Teacher } from '../users/teacher.entity.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; @@ -28,7 +28,7 @@ export class LearningObject { @ManyToMany({ entity: () => Teacher, }) - admins!: Teacher[]; + admins: Collection = new Collection(this); @Property({ type: 'string' }) title!: string; @@ -84,7 +84,7 @@ export class LearningObject { entity: () => Attachment, mappedBy: 'learningObject', }) - attachments: Attachment[] = []; + attachments: Collection = new Collection(this); @Property({ type: 'blob' }) content!: Buffer; diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts index fd870dcd..08543d22 100644 --- a/backend/src/entities/content/learning-path-node.entity.ts +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -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 { LearningPathTransition } from './learning-path-transition.entity.js'; import { Language } from '@dwengo-1/common/util/language'; @@ -26,7 +26,7 @@ export class LearningPathNode { @Property({ type: 'bool' }) startNode!: boolean; - @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) + @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] }) transitions!: Collection; @Property({ length: 3 }) diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 1b96d8ea..36e13766 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -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 { LearningPathRepository } from '../../data/content/learning-path-repository.js'; import { LearningPathNode } from './learning-path-node.entity.js'; @@ -24,6 +24,6 @@ export class LearningPath { @Property({ type: 'blob', nullable: true }) image: Buffer | null = null; - @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) + @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] }) nodes: Collection = new Collection(this); } diff --git a/backend/src/interfaces/assignment.ts b/backend/src/interfaces/assignment.ts index 7c5a0909..2dc158d2 100644 --- a/backend/src/interfaces/assignment.ts +++ b/backend/src/interfaces/assignment.ts @@ -20,6 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { description: assignment.description, learningPath: assignment.learningPathHruid, language: assignment.learningPathLanguage, + deadline: assignment.deadline ?? new Date(), groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), }; } @@ -31,6 +32,7 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi description: assignmentData.description, learningPathHruid: assignmentData.learningPath, learningPathLanguage: languageMap[assignmentData.language], + deadline: assignmentData.deadline, groups: [], }); } diff --git a/backend/src/interfaces/user.ts b/backend/src/interfaces/user.ts index f4413b5e..3084c494 100644 --- a/backend/src/interfaces/user.ts +++ b/backend/src/interfaces/user.ts @@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO { }; } +export function mapToUsername(user: { username: string }): string { + return user.username; +} + export function mapToUser(userData: UserDTO, userInstance: T): T { userInstance.username = userData.username; userInstance.firstName = userData.firstName; diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index 73a65b9a..24be4825 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -7,7 +7,6 @@ import * as express from 'express'; import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticationInfo } from './authentication-info.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; -import { ForbiddenException } from '../../exceptions/forbidden-exception.js'; const JWKS_CACHE = true; const JWKS_RATE_LIMIT = true; @@ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response } 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'); diff --git a/backend/src/middleware/auth/authenticated-request.d.ts b/backend/src/middleware/auth/authenticated-request.d.ts index 9737fa7e..af7630af 100644 --- a/backend/src/middleware/auth/authenticated-request.d.ts +++ b/backend/src/middleware/auth/authenticated-request.d.ts @@ -1,8 +1,15 @@ import { Request } from 'express'; import { JwtPayload } from 'jsonwebtoken'; 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 = Record, +> extends Request { // Properties are optional since the user is not necessarily authenticated. jwtPayload?: JwtPayload; auth?: AuthenticationInfo; diff --git a/backend/src/middleware/auth/checks/assignment-auth-checks.ts b/backend/src/middleware/auth/checks/assignment-auth-checks.ts new file mode 100644 index 00000000..bd9f51d7 --- /dev/null +++ b/backend/src/middleware/auth/checks/assignment-auth-checks.ts @@ -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)); +}); diff --git a/backend/src/middleware/auth/checks/auth-checks.ts b/backend/src/middleware/auth/checks/auth-checks.ts new file mode 100644 index 00000000..bf4891a3 --- /dev/null +++ b/backend/src/middleware/auth/checks/auth-checks.ts @@ -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>( + accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest) => boolean | Promise +): RequestHandler { + // Bypass authentication during testing + if (getEnvVar(envVars.RunMode) === 'test') { + return async ( + _req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + next(); + }; + } + + return async ( + req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + 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); diff --git a/backend/src/middleware/auth/checks/class-auth-checks.ts b/backend/src/middleware/auth/checks/class-auth-checks.ts new file mode 100644 index 00000000..ea75d21d --- /dev/null +++ b/backend/src/middleware/auth/checks/class-auth-checks.ts @@ -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 { + 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); +}); diff --git a/backend/src/middleware/auth/checks/group-auth-checker.ts b/backend/src/middleware/auth/checks/group-auth-checker.ts new file mode 100644 index 00000000..563edf57 --- /dev/null +++ b/backend/src/middleware/auth/checks/group-auth-checker.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/learning-content-auth-checks.ts b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts new file mode 100644 index 00000000..6942b425 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts @@ -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; +}); diff --git a/backend/src/middleware/auth/checks/learning-object-auth-checks.ts b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts new file mode 100644 index 00000000..9bf94799 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts @@ -0,0 +1,16 @@ +import { Language } from '@dwengo-1/common/util/language'; +import learningObjectService from '../../../services/learning-objects/learning-object-service.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); +}); diff --git a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts new file mode 100644 index 00000000..3668ab07 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/question-checks.ts b/backend/src/middleware/auth/checks/question-checks.ts new file mode 100644 index 00000000..76ede049 --- /dev/null +++ b/backend/src/middleware/auth/checks/question-checks.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/submission-checks.ts b/backend/src/middleware/auth/checks/submission-checks.ts new file mode 100644 index 00000000..893371c2 --- /dev/null +++ b/backend/src/middleware/auth/checks/submission-checks.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/teacher-invitation-checks.ts b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts new file mode 100644 index 00000000..0c6a790f --- /dev/null +++ b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts @@ -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 +); diff --git a/backend/src/middleware/auth/checks/user-auth-checks.ts b/backend/src/middleware/auth/checks/user-auth-checks.ts new file mode 100644 index 00000000..27228369 --- /dev/null +++ b/backend/src/middleware/auth/checks/user-auth-checks.ts @@ -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); diff --git a/backend/src/routes/answers.ts b/backend/src/routes/answers.ts index b74f76a0..58179197 100644 --- a/backend/src/routes/answers.ts +++ b/backend/src/routes/answers.ts @@ -1,16 +1,18 @@ import express from 'express'; 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 }); -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; diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts index 4503414d..f0250550 100644 --- a/backend/src/routes/assignments.ts +++ b/backend/src/routes/assignments.ts @@ -9,22 +9,25 @@ import { putAssignmentHandler, } from '../controllers/assignments.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 }); -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); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6f153836..ce9ee866 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,28 +1,35 @@ import express from 'express'; -import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; -import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; +import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; +import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; + const router = express.Router(); // Returns auth configuration for frontend -router.get('/config', (_req, res) => { - res.json(getFrontendAuthConfig()); -}); +router.get('/config', handleGetFrontendAuthConfig); 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!' }); }); 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!' }); }); 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!' }); }); -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; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index cef6fd72..8a35eb2a 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -14,33 +14,35 @@ import { putClassHandler, } from '../controllers/classes.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(); -// Root endpoint used to search objects -router.get('/', getAllClassesHandler); +router.get('/', adminOnly, 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); diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index 3043c23b..e8cb4c2d 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -8,22 +8,24 @@ import { getGroupSubmissionsHandler, putGroupHandler, } 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 }); -// Root endpoint used to search objects -router.get('/', getAllGroupsHandler); +router.get('/', onlyAllowIfHasAccessToAssignment, 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; diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index 7532765b..cae56b88 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,8 +1,17 @@ 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 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(); @@ -16,13 +25,21 @@ const router = express.Router(); // Route 2: list of object data // 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 // Query: language // Route to fetch data of one learning object based on its hruid // 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); @@ -32,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes); // Query: language, version (optional) // Route to fetch the HTML rendering of one learning object based on its hruid. // Example: http://localhost:3000/learningObject/un_ai7/html -router.get('/:hruid/html', getLearningObjectHTML); +router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML); // Parameter: hruid of learning object, name of attachment. // Query: language, version (optional). // Route to get the raw data of the attachment for one learning object based on its hruid. // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png -router.get('/:hruid/html/:attachmentName', getAttachment); +router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment); export default router; diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index efe17312..8f2f1249 100644 --- a/backend/src/routes/learning-paths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,7 @@ import express from 'express'; -import { getLearningPaths } from '../controllers/learning-paths.js'; +import { 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(); @@ -22,6 +24,10 @@ const router = express.Router(); // Route to fetch learning paths based on a theme // 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; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts index 5135c197..6cad3c01 100644 --- a/backend/src/routes/questions.ts +++ b/backend/src/routes/questions.ts @@ -1,20 +1,25 @@ import express from 'express'; import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.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 }); // Query language // Root endpoint used to search objects -router.get('/', getAllQuestionsHandler); +router.get('/', authenticatedOnly, getAllQuestionsHandler); -router.post('/', createQuestionHandler); - -router.delete('/:seq', deleteQuestionHandler); +router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler); // 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); diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index 99d4312c..ae141913 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -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('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); -router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); -router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); +router.use( + '/class', + 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; diff --git a/backend/src/routes/student-join-requests.ts b/backend/src/routes/student-join-requests.ts index daf79f09..a49984c7 100644 --- a/backend/src/routes/student-join-requests.ts +++ b/backend/src/routes/student-join-requests.ts @@ -5,15 +5,19 @@ import { getStudentRequestHandler, getStudentRequestsHandler, } 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 }); -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; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 0f5d5349..9ecf4688 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -11,33 +11,37 @@ import { getStudentSubmissionsHandler, } from '../controllers/students.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(); // 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 -router.get('/:username', getStudentHandler); +router.get('/:username', preventImpersonation, getStudentHandler); // 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 -router.get('/:username/submissions', getStudentSubmissionsHandler); +router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler); // 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 -router.get('/:username/groups', getStudentGroupsHandler); +router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler); // A list of questions a user has created -router.get('/:username/questions', getStudentQuestionsHandler); +router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler); router.use('/:username/joinRequests', joinRequestRouter); diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index fc0aa7c6..88309ce8 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -1,15 +1,15 @@ import express from 'express'; 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 }); -// Root endpoint used to search objects -router.get('/', getSubmissionsHandler); +router.get('/', adminOnly, getSubmissionsHandler); -router.post('/', createSubmissionHandler); +router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler); -// Information about an submission with id 'id' -router.get('/:id', getSubmissionHandler); +router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler); -router.delete('/:id', deleteSubmissionHandler); +router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler); export default router; diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts index 23b943d0..90117088 100644 --- a/backend/src/routes/teacher-invitations.ts +++ b/backend/src/routes/teacher-invitations.ts @@ -6,17 +6,24 @@ import { getInvitationHandler, updateInvitationHandler, } 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 }); -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; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index 6ff5fd9c..e7179b3d 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -7,34 +7,34 @@ import { getTeacherAssignmentsHandler, getTeacherClassHandler, getTeacherHandler, - getTeacherQuestionHandler, getTeacherStudentHandler, updateStudentJoinRequestHandler, } from '../controllers/teachers.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(); // 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/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', updateStudentJoinRequestHandler); +router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); // Invitations to other classes a teacher received router.use('/invitations', invitationRouter); diff --git a/backend/src/routes/themes.ts b/backend/src/routes/themes.ts index b135d44f..6310c2ab 100644 --- a/backend/src/routes/themes.ts +++ b/backend/src/routes/themes.ts @@ -1,14 +1,15 @@ import express from 'express'; import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; +import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; const router = express.Router(); // Query: 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) // Route to fetch list of hruids based on theme -router.get('/:theme', getHruidsByThemeHandler); +router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler); export default router; diff --git a/backend/src/services/answers.ts b/backend/src/services/answers.ts index ab603883..7ec5773a 100644 --- a/backend/src/services/answers.ts +++ b/backend/src/services/answers.ts @@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat return mapToAnswerDTO(answer); } -async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { +export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { const answerRepository = getAnswerRepository(); const question = await fetchQuestion(questionId); const answer = await answerRepository.findAnswer(question, sequenceNumber); diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index b75fe82f..e5026020 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou return group; } +export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise { + 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 { const group = await fetchGroup(classId, assignmentNumber, groupNumber); return mapToGroupDTO(group, group.assignment.within); diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 0b805a56..fb0f70fe 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = { ); return learningObjects.filter((it) => it !== null); }, + + /** + * Returns all learning objects containing the given username as an admin. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + const learningObjectRepo = getLearningObjectRepository(); + const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername); + return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null); + }, }; export default databaseLearningObjectProvider; diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index 4a4bdc54..5ceca40c 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { return html; }, + + /** + * Obtain all learning objects who have the user with the given username as an admin. + */ + async getLearningObjectsAdministratedBy(_adminUsername: string): Promise { + return []; // The dwengo database does not contain any learning objects administrated by users. + }, }; export default dwengoApiLearningObjectProvider; diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts index 14848bc0..69ad268d 100644 --- a/backend/src/services/learning-objects/learning-object-provider.ts +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -20,4 +20,9 @@ export interface LearningObjectProvider { * Obtain a HTML-rendering of the learning object with the given identifier (as a string). */ getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise; + + /** + * Obtain all learning object who have the user with the given username as an admin. + */ + getLearningObjectsAdministratedBy(username: string): Promise; } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 7b4f47fc..6be10775 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; import databaseLearningObjectProvider from './database-learning-object-provider.js'; import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { 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 { if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { @@ -42,6 +47,66 @@ const learningObjectService = { async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { return getProvider(id).getLearningObjectHTML(id); }, + + /** + * Obtain all learning objects administrated by the user with the given username. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername); + }, + + /** + * Store the learning object in the given zip file in the database. + * @param learningObjectPath The path where the uploaded learning object resides. + * @param admins The usernames of the users which should be administrators of the learning object. + */ + async storeLearningObject(learningObjectPath: string, admins: string[]): Promise { + 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 { + const learningObjectRepository = getLearningObjectRepository(); + return await learningObjectRepository.removeByIdentifier(id); + }, + + /** + * Returns a list of the usernames of the administrators of the learning object with the given identifier. + * @throws NotFoundException if the specified learning object was not found in the database. + */ + async getAdmins(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + const learningObject = await learningObjectRepo.findByIdentifier(id); + if (!learningObject) { + throw new NotFoundException('learningObjectNotFound'); + } + return learningObject.admins.map((admin) => admin.username); + }, }; export default learningObjectService; diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts new file mode 100644 index 00000000..9b9c4d9f --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -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 { + 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 { + const buf = await file.buffer(); + const content = buf.toString(); + return JSON.parse(content); +} + +async function processFile(file: unzipper.File): Promise { + return await file.buffer(); +} diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index fe05dda1..40444b7d 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js'; import learningObjectService from '../learning-objects/learning-object-service.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; -import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; +import { getLastSubmissionForGroup, idFromLearningPathNode, isTransitionPossible } from './learning-path-personalization-util.js'; import { FilteredLearningObject, LearningObjectNode, @@ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language'; import { Group } from '../../entities/assignments/group.entity'; import { Collection } from '@mikro-orm/core'; 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 @@ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection): ) ) ); - 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; } @@ -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- const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); + const nodesActuallyOnPath = traverseLearningPath(convertedNodes); + return { _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. __order: order, @@ -71,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb image: image, title: learningPath.title, nodes: convertedNodes, - num_nodes: learningPath.nodes.length, - num_nodes_left: convertedNodes.filter((it) => !it.done).length, + num_nodes: nodesActuallyOnPath.length, + num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length, keywords: keywords.join(' '), target_ages: targetAges, max_age: Math.max(...targetAges), @@ -95,14 +105,22 @@ async function convertNode( personalizedFor: Group | undefined, nodesToLearningObjects: Map ): Promise { - const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; + const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningPathNode(node), personalizedFor) : null; const transitions = node.transitions .filter( (trans) => !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. ) - .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 { _id: learningObject.uuid, 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. */ @@ -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 { + const repo = getLearningPathRepository(); + const paths = await repo.findAllByAdminUsername(adminUsername); + return await Promise.all(paths.map(async (result, index) => convertLearningPath(result, index))); + }, + /** * Search learning paths in the database using the given search string. */ diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts index 110cd570..e8176ad1 100644 --- a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -3,11 +3,33 @@ import { DWENGO_API_BASE } from '../../config.js'; import { LearningPathProvider } from './learning-path-provider.js'; import { getLogger, Logger } from '../../logging/initalize.js'; 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(); +/** + * 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 { + 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 = { - async fetchLearningPaths(hruids: string[], language: string, source: string): Promise { + async fetchLearningPaths(hruids: string[], language: string, source: string, personalizedFor: Group): Promise { if (hruids.length === 0) { return { success: false, @@ -32,19 +54,30 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { }; } + await Promise.all(learningPaths?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); + return { success: true, source, data: learningPaths, }; }, - async searchLearningPaths(query: string, language: string): Promise { + async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise { const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; const params = { all: query, language }; const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); + + if (searchResults) { + await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); + } + return searchResults ?? []; }, + + async getLearningPathsAdministratedBy(_adminUsername: string) { + return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user. + }, }; export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts index a10d5ead..7651baa3 100644 --- a/backend/src/services/learning-paths/learning-path-personalization-util.ts +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -5,18 +5,36 @@ import { getSubmissionRepository } from '../../data/repositories.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { JSONPath } from 'jsonpath-plus'; +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 */ -export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise { +export async function getLastSubmissionForGroup(learningObjectId: LearningObjectIdentifier, pathFor: Group): Promise { 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, language: node.language, version: node.version, }; - return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); } /** diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 086777bd..0cf507ca 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -15,4 +15,9 @@ export interface LearningPathProvider { * Search learning paths in the data source using the given search string. */ searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise; + + /** + * Get all learning paths which have the teacher with the given user as an administrator. + */ + getLearningPathsAdministratedBy(adminUsername: string): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index b20d8f97..56b3be7a 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -1,7 +1,7 @@ import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; import databaseLearningPathProvider from './database-learning-path-provider.js'; import { envVars, getEnvVar } from '../../util/envVars.js'; -import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { Language } from '@dwengo-1/common/util/language'; import { Group } from '../../entities/assignments/group.entity.js'; import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; @@ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { mapToTeacher } from '../../interfaces/teacher.js'; import { Collection } from '@mikro-orm/core'; +import { NotFoundException } from '../../exceptions/not-found-exception.js'; +import { BadRequestException } from '../../exceptions/bad-request-exception.js'; +import learningObjectService from '../learning-objects/learning-object-service.js'; const userContentPrefix = getEnvVar(envVars.UserContentPrefix); const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; @@ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L const fromNode = nodes.find( (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version )!; - const transitions = nodeDto.transitions - .map((transDto, i) => { - const toNode = nodes.find( - (it) => - it.learningObjectHruid === transDto.next.hruid && - it.language === transDto.next.language && - it.version === transDto.next.version - ); + const transitions = nodeDto.transitions.map((transDto, i) => { + const toNode = nodes.find( + (it) => + it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version + ); - if (toNode) { - return repo.createTransition({ - transitionNumber: i, - node: fromNode, - next: toNode, - condition: transDto.condition ?? 'true', - }); - } - return undefined; - }) - .filter((it) => it) - .map((it) => it!); + if (toNode) { + return repo.createTransition({ + transitionNumber: i, + node: fromNode, + next: toNode, + condition: transDto.condition ?? 'true', + }); + } + throw new BadRequestException( + `Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.` + ); + }); fromNode.transitions = new Collection(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 { + const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername))); + return providerResponses.flat(); + }, + /** * Search learning paths in the data source using the given search string. */ @@ -119,11 +127,67 @@ const learningPathService = { * Add a new learning path to the database. * @param dto Learning path DTO from which the learning path will be created. * @param admins Teachers who should become an admin of the learning path. + * @returns the created learning path. */ - async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { const repo = getLearningPathRepository(); + + const userContentPrefix = getEnvVar(envVars.UserContentPrefix); + if (!dto.hruid.startsWith(userContentPrefix)) { + dto.hruid = userContentPrefix + dto.hruid; + } + 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 { + const repo = getLearningPathRepository(); + + const deletedPath = await repo.deleteByHruidAndLanguage(id.hruid, id.language); + if (deletedPath) { + return deletedPath; + } + throw new NotFoundException('No learning path with the given identifier found.'); + }, + + /** + * Returns a list of the usernames of the administrators of the learning path with the given identifier. + * @param id The identifier of the learning path whose admins should be fetched. + */ + async getAdmins(id: LearningPathIdentifier): Promise { + const repo = getLearningPathRepository(); + const path = await repo.findByHruidAndLanguage(id.hruid, id.language); + if (!path) { + throw new NotFoundException('No learning path with the given identifier found.'); + } + return path.admins.map((admin) => admin.username); }, }; diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts index 09643cd2..c6d978d8 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -13,6 +13,7 @@ import { fetchStudent } from './students.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { FALLBACK_VERSION_NUM } from '../config.js'; import { fetchAssignment } from './assignments.js'; +import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getQuestionsAboutLearningObjectInAssignment( loId: LearningObjectIdentifier, @@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat 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({ loId, author, - inGroup: inGroup!, + inGroup: inGroup, content, }); diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index 77ec6648..3ccd2dba 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; 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 { const studentRepository = getStudentRepository(); @@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchStudent(username: string): Promise { @@ -42,7 +43,7 @@ export async function fetchStudent(username: string): Promise { const user = await studentRepository.findByUsername(username); if (!user) { - throw new NotFoundException('Student with username not found'); + throw new NotFoundException(`Student with username ${username} not found`); } return user; @@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise { const newStudent = mapToStudent(userData); await studentRepository.save(newStudent, { preventOverwrite: true }); - return userData; + return mapToStudentDTO(newStudent); } export async function createOrUpdateStudent(userData: StudentDTO): Promise { diff --git a/backend/src/services/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts index aead8715..0457496f 100644 --- a/backend/src/services/teacher-invitations.ts +++ b/backend/src/services/teacher-invitations.ts @@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); @@ -41,7 +34,7 @@ export async function getAllTeachers(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchTeacher(username: string): Promise { @@ -60,7 +53,8 @@ export async function getTeacher(username: string): Promise { return mapToTeacherDTO(user); } -export async function createTeacher(userData: TeacherDTO): Promise { +// TODO update parameter +export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); 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 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) { return students; @@ -133,28 +129,6 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro return students.map((student) => student.username); } -export async function getTeacherQuestions(username: string, full: boolean): Promise { - 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 { const classRepository: ClassRepository = getClassRepository(); const cls: Class | null = await classRepository.findById(classId); diff --git a/backend/tests/controllers/assignments.test.ts b/backend/tests/controllers/assignments.test.ts new file mode 100644 index 00000000..88cac366 --- /dev/null +++ b/backend/tests/controllers/assignments.test.ts @@ -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; + let res: Partial; + + 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() })); + }); +}); diff --git a/backend/tests/controllers/classes.test.ts b/backend/tests/controllers/classes.test.ts new file mode 100644 index 00000000..d9614a3b --- /dev/null +++ b/backend/tests/controllers/classes.test.ts @@ -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; + let res: Partial; + + 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() })); + }); +}); diff --git a/backend/tests/controllers/groups.test.ts b/backend/tests/controllers/groups.test.ts new file mode 100644 index 00000000..f9e35cea --- /dev/null +++ b/backend/tests/controllers/groups.test.ts @@ -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; + let res: Partial; + + 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() })); + }); +}); diff --git a/backend/tests/controllers/students.test.ts b/backend/tests/controllers/students.test.ts index aca29de1..dbd22d46 100644 --- a/backend/tests/controllers/students.test.ts +++ b/backend/tests/controllers/students.test.ts @@ -21,6 +21,7 @@ import { BadRequestException } from '../../src/exceptions/bad-request-exception. import { ConflictException } from '../../src/exceptions/conflict-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; +import { getClass02 } from '../test_assets/classes/classes.testdata'; describe('Student controllers', () => { let req: Partial; @@ -186,7 +187,7 @@ describe('Student controllers', () => { it('Get join request by student and class', async () => { req = { - params: { username: 'PinkFloyd', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + params: { username: 'PinkFloyd', classId: getClass02().classId }, }; await getStudentRequestHandler(req as Request, res as Response); @@ -201,7 +202,7 @@ describe('Student controllers', () => { it('Create and delete join request', async () => { req = { params: { username: 'TheDoors' }, - body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + body: { classId: getClass02().classId }, }; await createStudentRequestHandler(req as Request, res as Response); @@ -209,7 +210,7 @@ describe('Student controllers', () => { expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() })); req = { - params: { username: 'TheDoors', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + params: { username: 'TheDoors', classId: getClass02().classId }, }; 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 () => { req = { 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); @@ -231,7 +232,7 @@ describe('Student controllers', () => { it('Create join request duplicate', async () => { req = { 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); diff --git a/backend/tests/controllers/submissions.test.ts b/backend/tests/controllers/submissions.test.ts new file mode 100644 index 00000000..942b51f8 --- /dev/null +++ b/backend/tests/controllers/submissions.test.ts @@ -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; + let res: Partial; + + 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() })); + }); +}); diff --git a/backend/tests/controllers/teacher-invitations.test.ts b/backend/tests/controllers/teacher-invitations.test.ts index ed2f5ebf..675efea1 100644 --- a/backend/tests/controllers/teacher-invitations.test.ts +++ b/backend/tests/controllers/teacher-invitations.test.ts @@ -12,6 +12,7 @@ import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invit import { getClassHandler } from '../../src/controllers/classes'; import { BadRequestException } from '../../src/exceptions/bad-request-exception'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; +import { getClass02 } from '../test_assets/classes/classes.testdata'; describe('Teacher controllers', () => { let req: Partial; @@ -57,7 +58,7 @@ describe('Teacher controllers', () => { const body = { sender: 'LimpBizkit', receiver: 'testleerkracht1', - class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + class: getClass02().classId, } as TeacherInvitationData; req = { body }; @@ -67,7 +68,7 @@ describe('Teacher controllers', () => { params: { sender: 'LimpBizkit', receiver: 'testleerkracht1', - classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + classId: getClass02().classId, }, body: { accepted: 'false' }, }; @@ -80,7 +81,7 @@ describe('Teacher controllers', () => { params: { sender: 'LimpBizkit', receiver: 'FooFighters', - classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + classId: getClass02().classId, }, }; await getInvitationHandler(req as Request, res as Response); @@ -100,7 +101,7 @@ describe('Teacher controllers', () => { const body = { sender: 'LimpBizkit', receiver: 'FooFighters', - class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + class: getClass02().classId, } as TeacherInvitationData; req = { body }; @@ -111,7 +112,7 @@ describe('Teacher controllers', () => { req = { params: { - id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + id: getClass02().classId, }, }; diff --git a/backend/tests/controllers/teachers.test.ts b/backend/tests/controllers/teachers.test.ts index a73a79a5..fcf80d90 100644 --- a/backend/tests/controllers/teachers.test.ts +++ b/backend/tests/controllers/teachers.test.ts @@ -15,8 +15,8 @@ import { import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js'; -import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { getClassHandler } from '../../src/controllers/classes'; +import { getClass02 } from '../test_assets/classes/classes.testdata'; describe('Teacher controllers', () => { let req: Partial; @@ -96,7 +96,7 @@ describe('Teacher controllers', () => { }); it('Teacher list', async () => { - req = { query: { full: 'true' } }; + req = { query: { full: 'false' } }; await getAllTeachersHandler(req as Request, res as Response); @@ -104,8 +104,7 @@ describe('Teacher controllers', () => { const result = jsonMock.mock.lastCall?.[0]; - const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); - expect(teacherUsernames).toContain('testleerkracht1'); + expect(result.teachers).toContain('testleerkracht1'); expect(result.teachers).toHaveLength(5); }); @@ -169,7 +168,7 @@ describe('Teacher controllers', () => { it('Get join requests by class', async () => { req = { - params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + params: { classId: getClass02().classId }, }; await getStudentJoinRequestHandler(req as Request, res as Response); @@ -183,7 +182,7 @@ describe('Teacher controllers', () => { it('Update join request status', async () => { req = { - params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', studentUsername: 'PinkFloyd' }, + params: { classId: getClass02().classId, studentUsername: 'PinkFloyd' }, body: { accepted: 'true' }, }; @@ -201,7 +200,7 @@ describe('Teacher controllers', () => { expect(status).toBeTruthy(); req = { - params: { id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + params: { id: getClass02().classId }, }; await getClassHandler(req as Request, res as Response); diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts index 74c858b3..f5452035 100644 --- a/backend/tests/data/assignments/assignments.test.ts +++ b/backend/tests/data/assignments/assignments.test.ts @@ -3,6 +3,7 @@ import { setupTestApp } from '../../setup-tests'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { getClass02 } from '../../test_assets/classes/classes.testdata'; describe('AssignmentRepository', () => { let assignmentRepository: AssignmentRepository; @@ -15,7 +16,7 @@ describe('AssignmentRepository', () => { }); 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); expect(assignment).toBeTruthy(); @@ -23,7 +24,7 @@ describe('AssignmentRepository', () => { }); 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_!); expect(assignments).toBeTruthy(); diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts index efd477ab..2368085d 100644 --- a/backend/tests/data/assignments/groups.test.ts +++ b/backend/tests/data/assignments/groups.test.ts @@ -4,6 +4,7 @@ import { GroupRepository } from '../../../src/data/assignments/group-repository' import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata'; describe('GroupRepository', () => { let groupRepository: GroupRepository; @@ -18,7 +19,8 @@ describe('GroupRepository', () => { }); 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 group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); @@ -27,7 +29,7 @@ describe('GroupRepository', () => { }); 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 groups = await groupRepository.findAllGroupsForAssignment(assignment!); @@ -37,7 +39,7 @@ describe('GroupRepository', () => { }); 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); await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts index ea2341bc..77c163d2 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -18,6 +18,7 @@ import { Submission } from '../../../src/entities/assignments/submission.entity' import { Class } from '../../../src/entities/classes/class.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; +import { getClass01 } from '../../test_assets/classes/classes.testdata'; describe('SubmissionRepository', () => { let submissionRepository: SubmissionRepository; @@ -54,7 +55,7 @@ describe('SubmissionRepository', () => { it('should find the most recent submission for a group', async () => { 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 group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); @@ -67,7 +68,7 @@ describe('SubmissionRepository', () => { let assignment: Assignment | null; let loId: LearningObjectIdentifier; 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); loId = { hruid: 'id02', diff --git a/backend/tests/data/classes/class-join-request.test.ts b/backend/tests/data/classes/class-join-request.test.ts index afb83766..4e18c116 100644 --- a/backend/tests/data/classes/class-join-request.test.ts +++ b/backend/tests/data/classes/class-join-request.test.ts @@ -4,6 +4,7 @@ import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; import { StudentRepository } from '../../../src/data/users/student-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { getClass02, getClass03 } from '../../test_assets/classes/classes.testdata'; describe('ClassJoinRequestRepository', () => { let classJoinRequestRepository: ClassJoinRequestRepository; @@ -26,7 +27,7 @@ describe('ClassJoinRequestRepository', () => { }); 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_!); expect(requests).toBeTruthy(); @@ -35,7 +36,7 @@ describe('ClassJoinRequestRepository', () => { it('should not find a removed request', async () => { 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_!); const request = await classJoinRequestRepository.findAllRequestsBy(student!); diff --git a/backend/tests/data/classes/classes.test.ts b/backend/tests/data/classes/classes.test.ts index f87f83ed..6930e5ac 100644 --- a/backend/tests/data/classes/classes.test.ts +++ b/backend/tests/data/classes/classes.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { ClassRepository } from '../../../src/data/classes/class-repository'; import { setupTestApp } from '../../setup-tests'; import { getClassRepository } from '../../../src/data/repositories'; +import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata'; describe('ClassRepository', () => { let classRepository: ClassRepository; @@ -18,16 +19,16 @@ describe('ClassRepository', () => { }); 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?.displayName).toBe('class01'); }); 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(); }); diff --git a/backend/tests/data/classes/teacher-invitation.test.ts b/backend/tests/data/classes/teacher-invitation.test.ts index f8afa36d..664e41d2 100644 --- a/backend/tests/data/classes/teacher-invitation.test.ts +++ b/backend/tests/data/classes/teacher-invitation.test.ts @@ -4,6 +4,7 @@ import { getClassRepository, getTeacherInvitationRepository, getTeacherRepositor import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; import { TeacherRepository } from '../../../src/data/users/teacher-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata'; describe('ClassRepository', () => { let teacherInvitationRepository: TeacherInvitationRepository; @@ -34,7 +35,7 @@ describe('ClassRepository', () => { }); 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_!); expect(invitations).toBeTruthy(); @@ -42,7 +43,7 @@ describe('ClassRepository', () => { }); 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 receiver = await teacherRepository.findByUsername('LimpBizkit'); await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts index 8ad2d47c..950845a6 100644 --- a/backend/tests/data/questions/questions.test.ts +++ b/backend/tests/data/questions/questions.test.ts @@ -14,6 +14,7 @@ import { Language } from '@dwengo-1/common/util/language'; import { Question } from '../../../src/entities/questions/question.entity'; import { Class } from '../../../src/entities/classes/class.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { getClass01 } from '../../test_assets/classes/classes.testdata'; describe('QuestionRepository', () => { let questionRepository: QuestionRepository; @@ -37,7 +38,7 @@ describe('QuestionRepository', () => { const id = new LearningObjectIdentifier('id03', Language.English, 1); 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 group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001); await questionRepository.createQuestion({ @@ -56,7 +57,7 @@ describe('QuestionRepository', () => { let assignment: Assignment | null; let loId: LearningObjectIdentifier; 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); loId = { hruid: 'id05', diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts index 337ec98f..dc477828 100644 --- a/backend/tests/test_assets/assignments/assignments.testdata.ts +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -6,13 +6,20 @@ import { testLearningPathWithConditions } from '../content/learning-paths.testda import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; 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, { id: 21000, within: classes[0], title: 'dire straits', description: 'reading', - learningPathHruid: 'id02', + learningPathHruid: 'un_ai', learningPathLanguage: Language.English, + deadline: today, groups: [], }); @@ -23,6 +30,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign description: 'reading', learningPathHruid: 'id01', learningPathLanguage: Language.English, + deadline: futureDate, groups: [], }); @@ -33,6 +41,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign description: 'will be deleted', learningPathHruid: 'id02', learningPathLanguage: Language.English, + deadline: pastDate, groups: [], }); @@ -43,6 +52,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign description: 'with a description', learningPathHruid: 'id01', learningPathLanguage: Language.English, + deadline: pastDate, 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.', learningPathHruid: testLearningPathWithConditions.hruid, learningPathLanguage: testLearningPathWithConditions.language as Language, + deadline: futureDate, groups: [], }); diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts index 7b5f2976..ae522a81 100644 --- a/backend/tests/test_assets/classes/classes.testdata.ts +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -10,7 +10,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass01: Teacher[] = teachers.slice(4, 5); class01 = em.create(Class, { - classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', + classId: 'X2J9QT', // 8764b861-90a6-42e5-9732-c0d9eb2f55f9 displayName: 'class01', teachers: teacherClass01, students: studentsClass01, @@ -20,7 +20,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass02: Teacher[] = teachers.slice(1, 2); class02 = em.create(Class, { - classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + classId: '7KLPMA', // 34d484a1-295f-4e9f-bfdc-3e7a23d86a89 displayName: 'class02', teachers: teacherClass02, students: studentsClass02, @@ -30,7 +30,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass03: Teacher[] = teachers.slice(2, 3); class03 = em.create(Class, { - classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', + classId: 'R0D3UZ', // 80dcc3e0-1811-4091-9361-42c0eee91cfa displayName: 'class03', teachers: teacherClass03, students: studentsClass03, @@ -40,14 +40,14 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass04: Teacher[] = teachers.slice(2, 3); class04 = em.create(Class, { - classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', + classId: 'Q8N5YC', // 33d03536-83b8-4880-9982-9bbf2f908ddf displayName: 'class04', teachers: teacherClass04, students: studentsClass04, }); classWithTestleerlingAndTestleerkracht = em.create(Class, { - classId: 'a75298b5-18aa-471d-8eeb-5d77eb989393', + classId: 'ZAV71B', // Was a75298b5-18aa-471d-8eeb-5d77eb989393 displayName: 'Testklasse', teachers: [getTestleerkracht1()], students: [getTestleerling1()], diff --git a/common/src/interfaces/assignment.ts b/common/src/interfaces/assignment.ts index fb7dfbf0..677221f1 100644 --- a/common/src/interfaces/assignment.ts +++ b/common/src/interfaces/assignment.ts @@ -7,6 +7,7 @@ export interface AssignmentDTO { description: string; learningPath: string; language: string; + deadline: Date; groups: GroupDTO[] | string[][]; } diff --git a/common/src/interfaces/learning-content.ts b/common/src/interfaces/learning-content.ts index 582e0086..453d1bac 100644 --- a/common/src/interfaces/learning-content.ts +++ b/common/src/interfaces/learning-content.ts @@ -25,8 +25,8 @@ export interface LearningObjectNode { language: Language; start_node?: boolean; transitions: Transition[]; - created_at: string; - updatedAt: string; + created_at?: string; + updatedAt?: string; done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. } @@ -79,6 +79,8 @@ export interface LearningObjectMetadata { target_ages: number[]; content_type: string; // Markdown, image, etc. content_location?: string; + copyright?: string; + license?: string; skos_concepts?: string[]; return_value?: ReturnValue; } diff --git a/common/src/interfaces/question.ts b/common/src/interfaces/question.ts index 172d14b7..2d681fc0 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -13,8 +13,8 @@ export interface QuestionDTO { export interface QuestionData { author?: string; - content: string; inGroup: GroupDTO; + content: string; } export interface QuestionId { diff --git a/common/src/util/account-types.ts b/common/src/util/account-types.ts new file mode 100644 index 00000000..f0957019 --- /dev/null +++ b/common/src/util/account-types.ts @@ -0,0 +1,4 @@ +export enum AccountType { + Student = 'student', + Teacher = 'teacher', +} diff --git a/compose.production.yml b/compose.production.yml index 65dc199b..f28e24ad 100644 --- a/compose.production.yml +++ b/compose.production.yml @@ -67,8 +67,6 @@ services: - 'traefik.enable=true' - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' - '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: - keycloak-db volumes: @@ -95,6 +93,9 @@ services: - '80:80/tcp' - '443:443/tcp' command: + # Enable web UI + - '--api=true' + # Add Docker provider - '--providers.docker=true' - '--providers.docker.exposedbydefault=false' @@ -115,6 +116,17 @@ services: - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' - '--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 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -137,8 +149,10 @@ services: dashboards: image: grafana/grafana:latest - ports: - - '9002:3000' + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' + - 'traefik.http.services.graphs.loadbalancer.server.port=3000' restart: unless-stopped volumes: - dwengo_grafana_data:/var/lib/grafana diff --git a/compose.staging.yml b/compose.staging.yml index 253ab7d5..3d833436 100644 --- a/compose.staging.yml +++ b/compose.staging.yml @@ -60,6 +60,13 @@ services: # Add web entrypoint - '--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: - '9000:8080' - '80:80/tcp' @@ -82,8 +89,12 @@ services: image: grafana/grafana:latest ports: - '9002:3000' + labels: + - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' + - 'traefik.http.services.graphs.loadbalancer.server.port=3000' volumes: - dwengo_grafana_data:/var/lib/grafana + - ./config/grafana/grafana.ini:/etc/grafana/grafana.ini restart: unless-stopped volumes: diff --git a/config/grafana/grafana.ini b/config/grafana/grafana.ini new file mode 100644 index 00000000..7421cb3f --- /dev/null +++ b/config/grafana/grafana.ini @@ -0,0 +1,8 @@ +[server] + +root_url = http://localhost:3000/graphs +serve_from_sub_path = true + +[security] + +admin_user = dwengo.org diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 796369d1..07523a32 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -26,7 +26,59 @@ const doc = { ], components: { 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', flows: { implicit: { @@ -39,7 +91,7 @@ const doc = { }, }, }, - teacher: { + teacherProduction: { type: 'oauth2', flows: { implicit: { diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9cbb61ea..1ddb8dc0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,22 +3,28 @@ FROM node:22 AS build-stage # install simple http server for serving static content RUN npm install -g http-server -WORKDIR /app +WORKDIR /app/dwengo # Install dependencies COPY package*.json ./ COPY ./frontend/package.json ./frontend/ +# Frontend depends on common +COPY common/package.json ./common/ RUN npm install --silent # Build the frontend # Root tsconfig.json -COPY tsconfig.json ./ -COPY assets ./assets/ +COPY tsconfig.json tsconfig.build.json ./ -WORKDIR /app/frontend +COPY assets ./assets +COPY common ./common + +RUN npm run build --workspace=common + +WORKDIR /app/dwengo/frontend COPY frontend ./ @@ -28,8 +34,8 @@ FROM nginx:stable AS production-stage COPY config/nginx/nginx.conf /etc/nginx/nginx.conf -COPY --from=build-stage /app/assets /usr/share/nginx/html/assets -COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html +COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets +COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html EXPOSE 8080 diff --git a/frontend/e2e/assignments.spec.ts b/frontend/e2e/assignments.spec.ts new file mode 100644 index 00000000..1279ffde --- /dev/null +++ b/frontend/e2e/assignments.spec.ts @@ -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(); +}); diff --git a/frontend/e2e/basic-learning.spec.ts b/frontend/e2e/basic-learning.spec.ts index f7438454..03be6e10 100644 --- a/frontend/e2e/basic-learning.spec.ts +++ b/frontend/e2e/basic-learning.spec.ts @@ -1,8 +1,16 @@ -import { test, expect } from "./fixtures.js"; +import { test, expect } from "@playwright/test"; 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.getByText("Nature and climate").click(); await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); diff --git a/frontend/e2e/basic-learning.ts b/frontend/e2e/basic-learning.ts deleted file mode 100644 index 157debb0..00000000 --- a/frontend/e2e/basic-learning.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from "./fixtures.js"; - -test("myTest", async ({ page }) => { - await expect(page).toHaveURL("/"); -}); diff --git a/frontend/e2e/class.spec.ts b/frontend/e2e/class.spec.ts new file mode 100644 index 00000000..b3ef29b9 --- /dev/null +++ b/frontend/e2e/class.spec.ts @@ -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"); +}); diff --git a/frontend/package.json b/frontend/package.json index 1a60fe58..c9bea614 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,18 +17,18 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dwengo-1/common": "^0.2.0", "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", "@vueuse/core": "^13.1.0", "axios": "^1.8.2", - "interactjs": "^1.10.27", + "json-editor-vue": "^0.18.1", "oidc-client-ts": "^3.1.0", "rollup": "^4.40.0", "uuid": "^11.1.0", "vue": "^3.5.13", "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", - "vuedraggable": "^2.24.3", "vuetify": "^3.7.12", "wait-on": "^8.0.3" }, diff --git a/frontend/src/assets/common.css b/frontend/src/assets/common.css new file mode 100644 index 00000000..bcc5d39f --- /dev/null +++ b/frontend/src/assets/common.css @@ -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; + } +} diff --git a/frontend/src/components/BrowseThemes.vue b/frontend/src/components/BrowseThemes.vue index b65c4e26..738ea7fb 100644 --- a/frontend/src/components/BrowseThemes.vue +++ b/frontend/src/components/BrowseThemes.vue @@ -5,6 +5,7 @@ import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; import { useThemeQuery } from "@/queries/themes.ts"; import type { Theme } from "@/data-objects/theme.ts"; + import authService from "@/services/auth/auth-service"; const props = defineProps({ selectedTheme: { type: String, required: true }, @@ -33,6 +34,8 @@ cards.value = themes; } }); + + const isTeacher = computed(() => authService.authState.activeRole === "teacher"); - + diff --git a/frontend/src/components/ButtonWithConfirmation.vue b/frontend/src/components/ButtonWithConfirmation.vue new file mode 100644 index 00000000..93dec470 --- /dev/null +++ b/frontend/src/components/ButtonWithConfirmation.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/src/components/LearningPathSearchField.vue b/frontend/src/components/LearningPathSearchField.vue index b8b71960..9afd62f6 100644 --- a/frontend/src/components/LearningPathSearchField.vue +++ b/frontend/src/components/LearningPathSearchField.vue @@ -31,4 +31,9 @@ > - + diff --git a/frontend/src/components/LearningPathsGrid.vue b/frontend/src/components/LearningPathsGrid.vue index 865c7166..8df08a00 100644 --- a/frontend/src/components/LearningPathsGrid.vue +++ b/frontend/src/components/LearningPathsGrid.vue @@ -53,9 +53,9 @@ white-space: normal; } .results-grid { - margin: 20px; + margin: 20px auto; display: flex; - align-items: stretch; + justify-content: center; gap: 20px; flex-wrap: wrap; } diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index e3734976..88b757b7 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -7,13 +7,17 @@ // Import assets import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; + import { useLocale } from "vuetify"; const { t, locale } = useI18n(); + const { current: vuetifyLocale } = useLocale(); const role = auth.authState.activeRole; const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable 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 .split(" ") .map((n) => n[0]) @@ -30,6 +34,7 @@ // Logic to change the language of the website to the selected language function changeLanguage(langCode: string): void { locale.value = langCode; + vuetifyLocale.value = langCode; localStorage.setItem("user-lang", langCode); } @@ -90,31 +95,34 @@ - - - - - {{ language.name }} - - - + + + + + {{ language.name }} + + + - {{ initials }} + + + + +
+ + {{ initials }} + +

{{ name }}

+

{{ username }}

+

{{ email }}

+ + {{ t("logout") }} + +
+
+
+
diff --git a/frontend/src/components/assignments/DeadlineSelector.vue b/frontend/src/components/assignments/DeadlineSelector.vue index 9295eec0..304c544c 100644 --- a/frontend/src/components/assignments/DeadlineSelector.vue +++ b/frontend/src/components/assignments/DeadlineSelector.vue @@ -1,49 +1,30 @@ - - diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 64f2363d..b2b0da01 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -37,6 +37,33 @@ export abstract class BaseController { return response.data; } + /** + * Sends a POST-request with a form-data body with the given file. + * + * @param path Relative path in the api to send the request to. + * @param formFieldName The name of the form field in which the file should be. + * @param file The file to upload. + * @param queryParams The query parameters. + * @returns The response the POST request generated. + */ + protected async postFile( + path: string, + formFieldName: string, + file: File, + queryParams?: QueryParams, + ): Promise { + const formData = new FormData(); + formData.append(formFieldName, file); + const response = await apiClient.post(this.absolutePathFor(path), formData, { + params: queryParams, + headers: { + "Content-Type": "multipart/form-data", + }, + }); + BaseController.assertSuccessResponse(response); + return response.data; + } + protected async delete(path: string, queryParams?: QueryParams): Promise { const response = await apiClient.delete(this.absolutePathFor(path), { params: queryParams }); BaseController.assertSuccessResponse(response); diff --git a/frontend/src/controllers/learning-objects.ts b/frontend/src/controllers/learning-objects.ts index d62ba1f4..a9ecf22f 100644 --- a/frontend/src/controllers/learning-objects.ts +++ b/frontend/src/controllers/learning-objects.ts @@ -14,4 +14,16 @@ export class LearningObjectController extends BaseController { async getHTML(hruid: string, language: Language, version: number): Promise { return this.get(`/${hruid}/html`, { language, version }, "document"); } + + async getAllAdministratedBy(admin: string): Promise { + return this.get("/", { admin }); + } + + async upload(learningObjectZip: File): Promise { + return this.postFile("/", "learningObject", learningObjectZip); + } + + async deleteLearningObject(hruid: string, language: Language, version: number): Promise { + return this.delete(`/${hruid}`, { language, version }); + } } diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index bad54286..1a1b0cc5 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -1,8 +1,8 @@ import { BaseController } from "@/controllers/base-controller.ts"; -import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { Language } from "@/data-objects/language.ts"; -import { single } from "@/utils/response-assertions.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import { LearningPath } from "@/data-objects/learning-paths/learning-path"; +import { NotFoundException } from "@/exception/not-found-exception"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathController extends BaseController { constructor() { @@ -24,10 +24,13 @@ export class LearningPathController extends BaseController { assignmentNo: forGroup?.assignmentNo, classId: forGroup?.classId, }); - return LearningPath.fromDTO(single(dtos)); + if (dtos.length === 0) { + throw new NotFoundException("learningPathNotFound"); + } + return LearningPath.fromDTO(dtos[0]); } - async getAllByTheme(theme: string): Promise { - const dtos = await this.get("/", { theme }); + async getAllByThemeAndLanguage(theme: string, language: Language): Promise { + const dtos = await this.get("/", { theme, language }); return dtos.map((dto) => LearningPath.fromDTO(dto)); } @@ -36,4 +39,20 @@ export class LearningPathController extends BaseController { const dtos = await this.get("/", query); return dtos.map((dto) => LearningPath.fromDTO(dto)); } + + async getAllByAdminRaw(admin: string): Promise { + return await this.get("/", { admin }); + } + + async postLearningPath(learningPath: Partial): Promise { + return await this.post("/", learningPath); + } + + async putLearningPath(learningPath: Partial): Promise { + return await this.put(`/${learningPath.hruid}/${learningPath.language}`, learningPath); + } + + async deleteLearningPath(hruid: string, language: string): Promise { + return await this.delete(`/${hruid}/${language}`); + } } diff --git a/frontend/src/controllers/teachers.ts b/frontend/src/controllers/teachers.ts index 76fe0162..1db43fe1 100644 --- a/frontend/src/controllers/teachers.ts +++ b/frontend/src/controllers/teachers.ts @@ -1,6 +1,5 @@ import { BaseController } from "@/controllers/base-controller.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; -import type { QuestionsResponse } from "@/controllers/questions.ts"; import type { ClassesResponse } from "@/controllers/classes.ts"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import type { AssignmentsResponse } from "./assignments"; @@ -45,10 +44,6 @@ export class TeacherController extends BaseController { return this.get(`/${username}/students`, { full }); } - async getQuestions(username: string, full = false): Promise { - return this.get(`/${username}/questions`, { full }); - } - async getStudentJoinRequests(username: string, classId: string): Promise { return this.get(`/${username}/joinRequests/${classId}`); } diff --git a/frontend/src/data-objects/learning-paths/learning-path-node.ts b/frontend/src/data-objects/learning-paths/learning-path-node.ts index 99bac8db..72737f97 100644 --- a/frontend/src/data-objects/learning-paths/learning-path-node.ts +++ b/frontend/src/data-objects/learning-paths/learning-path-node.ts @@ -1,5 +1,5 @@ import type { Language } from "@/data-objects/language.ts"; -import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; +import type { LearningObjectNode as LearningPathNodeDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathNode { public readonly learningobjectHruid: string; @@ -14,7 +14,7 @@ export class LearningPathNode { learningobjectHruid: string; version: number; language: Language; - transitions: { next: LearningPathNode; default: boolean }[]; + transitions: { next: LearningPathNode; default?: boolean }[]; createdAt: Date; updatedAt: Date; done?: boolean; @@ -22,7 +22,7 @@ export class LearningPathNode { this.learningobjectHruid = options.learningobjectHruid; this.version = options.version; this.language = options.language; - this.transitions = options.transitions; + this.transitions = options.transitions.map((it) => ({ next: it.next, default: it.default ?? false })); this.createdAt = options.createdAt; this.updatedAt = options.updatedAt; this.done = options.done || false; @@ -50,8 +50,8 @@ export class LearningPathNode { return undefined; }) .filter((it) => it !== undefined), - createdAt: new Date(dto.created_at), - updatedAt: new Date(dto.updatedAt), + createdAt: dto.created_at ? new Date(dto.created_at) : new Date(), + updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : new Date(), done: dto.done, }); } diff --git a/frontend/src/data-objects/learning-paths/learning-path.ts b/frontend/src/data-objects/learning-paths/learning-path.ts index d764d123..880aad85 100644 --- a/frontend/src/data-objects/learning-paths/learning-path.ts +++ b/frontend/src/data-objects/learning-paths/learning-path.ts @@ -1,6 +1,6 @@ import type { Language } from "@/data-objects/language.ts"; import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import type { LearningObjectNode, LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export interface LearningPathNodeDTO { _id: string; @@ -77,20 +77,26 @@ export class LearningPath { hruid: dto.hruid, title: dto.title, description: dto.description, - amountOfNodes: dto.num_nodes, - amountOfNodesLeft: dto.num_nodes_left, + amountOfNodes: dto.num_nodes ?? dto.nodes.length, + amountOfNodesLeft: dto.num_nodes_left ?? dto.nodes.length, keywords: dto.keywords.split(" "), - targetAges: { min: dto.min_age, max: dto.max_age }, + targetAges: { + min: dto.min_age ?? NaN, + max: dto.max_age ?? NaN, + }, startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), image: dto.image, }); } - static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { + static getStartNode(dto: LearningPathDTO): LearningObjectNode { const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); if (startNodeDtos.length < 1) { // The learning path has no starting node -> use the first node. - return dto.nodes[0]; + if (dto.nodes.length > 0) { + return dto.nodes[0]; + } + throw new Error("emptyLearningPath"); } // The learning path has 1 or more starting nodes -> use the first start node. return startNodeDtos[0]; } diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index fbdc652a..4d451607 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -21,6 +21,7 @@ "JoinClassExplanation": "Geben Sie den Code ein, den Ihnen die Lehrkraft mitgeteilt hat, um der Klasse beizutreten.", "invalidFormat": "Ungültiges Format", "submitCode": "senden", + "submit": "senden", "members": "Mitglieder", "themes": "Themen", "choose-theme": "Wählen Sie ein Thema", @@ -68,10 +69,10 @@ "pick-class": "Wählen Sie eine klasse", "choose-students": "Studenten auswählen", "create-group": "Gruppe erstellen", - "class": "klasse", + "class": "Klasse", "delete": "löschen", "view-assignment": "Auftrag anzeigen", - "code": "code", + "code": "Code", "invitations": "Einladungen", "createClass": "Klasse erstellen", "createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.", @@ -83,7 +84,7 @@ "onlyUse": "nur Buchstaben, Zahlen, Bindestriche (-) und Unterstriche (_) verwenden", "close": "schließen", "copied": "kopiert!", - "accept": "akzeptieren", + "accept": "Akzeptieren", "deny": "ablehnen", "sent": "sent", "failed": "fehlgeschlagen", @@ -104,13 +105,12 @@ "assignLearningPath": "Als Aufgabe geben", "group": "Gruppe", "description": "Beschreibung", - "no-submission": "keine vorlage", "submission": "Einreichung", "progress": "Fortschritte", "remove": "entfernen", "students": "Studenten", "classJoinRequests": "Beitrittsanfragen", - "reject": "ablehnen", + "reject": "Ablehnen", "areusure": "Sind Sie sicher?", "yes": "ja", "teachers": "Lehrer", @@ -121,5 +121,49 @@ "invite": "einladen", "assignmentIndicator": "AUFGABE", "searchAllLearningPathsTitle": "Alle Lernpfade durchsuchen", - "searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen." + "searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen.", + "no-students-found": "Diese Klasse hat keine Schüler.", + "no-invitations-found": "Sie haben keine ausstehenden Einladungen.", + "no-join-requests-found": "Es gibt keine ausstehenden Beitrittsanfragen für diese Klasse.", + "no-classes-found": "Sie sind noch keinem Kurs beigetreten.", + "classCreated": "Klasse erstellt!", + "success": "Erfolg", + "submitted": "eingereicht", + "see-submission": "Einsendung anzeigen", + "view-submissions": "Einsendungen anzeigen", + "valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein", + "creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut", + "no-assignments": "Derzeit gibt es keine Zuweisungen.", + "deadline": "deadline", + "learningObjects": "Lernobjekte", + "learningPaths": "Lernpfade", + "hruid": "HRUID", + "language": "Sprache", + "version": "Version", + "previewFor": "Vorschau für ", + "upload": "Hochladen", + "learningObjectUploadTitle": "Lernobjekt hochladen", + "uploadFailed": "Hochladen fehlgeschlagen", + "invalidZip": "Dies ist keine gültige ZIP-Datei.", + "emptyZip": "Diese ZIP-Datei ist leer.", + "missingMetadata": "Dieses Lernobjekt enthält keine metadata.json-Datei.", + "missingContent": "Dieses Lernobjekt enthält keine content.*-Datei.", + "open": "öffnen", + "editLearningPath": "Lernpfad bearbeiten", + "newLearningPath": "Neuen Lernpfad erstellen", + "saveChanges": "Änderungen speichern", + "newLearningObject": "Lernobjekt hochladen", + "confirmDialogTitle": "Bitte bestätigen", + "learningPathDeleteQuery": "Möchten Sie diesen Lernpfad wirklich löschen?", + "learningObjectDeleteQuery": "Möchten Sie dieses Lernobjekt wirklich löschen?", + "learningPathCantModifyId": "Der HRUID oder die Sprache eines Lernpfads kann nicht geändert werden.", + "error": "Fehler", + "ownLearningContentTitle": "Eigene Lerninhalte", + "ownLearningContentDescription": "Erstellen und verwalten Sie eigene Lernobjekte und Lernpfade. Nur für fortgeschrittene Nutzer.", + "learningPathNotFound": "Dieser Lernpfad konnte nicht gefunden werden.", + "emptyLearningPath": "Dieser Lernpfad enthält keine Lernobjekte.", + "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", + "targetAgesMandatory": "Zielalter müssen angegeben werden.", + "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", + "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index 51b9484b..a9307b6e 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -33,6 +33,7 @@ "JoinClassExplanation": "Enter the code the teacher has given you to join the class.", "invalidFormat": "Invalid format.", "submitCode": "submit", + "submit": "submit", "members": "Members", "themes": "Themes", "choose-theme": "Select a theme", @@ -68,21 +69,21 @@ "pick-class": "Pick a class", "choose-students": "Select students", "create-group": "Create group", - "class": "class", + "class": "Class", "delete": "delete", "view-assignment": "View assignment", - "code": "code", - "invitations": "invitations", - "createClass": "create class", + "code": "Code", + "invitations": "Invitations", + "createClass": "Create class", "classname": "classname", "EnterNameOfClass": "Enter a classname.", "create": "create", - "sender": "sender", + "sender": "Sender", "nameIsMandatory": "classname is mandatory", "onlyUse": "only use letters, numbers, dashes (-) and underscores (_)", "close": "close", "copied": "copied!", - "accept": "accept", + "accept": "Accept", "deny": "deny", "createClassInstructions": "Enter a name for your class and click on create. A window will appear with a code that you can copy. Give this code to your students and they will be able to join.", "sent": "sent", @@ -103,17 +104,16 @@ "assignLearningPath": "assign", "group": "Group", "description": "Description", - "no-submission": "no submission", "submission": "Submission", "progress": "Progress", "created": "created", "remove": "remove", - "students": "students", - "classJoinRequests": "join requests", - "reject": "reject", + "students": "Students", + "classJoinRequests": "Join requests", + "reject": "Reject", "areusure": "Are you sure?", "yes": "yes", - "teachers": "teachers", + "teachers": "Teachers", "accepted": "accepted", "rejected": "rejected", "enterUsername": "enter the username of the teacher you would like to invite", @@ -121,6 +121,50 @@ "invite": "invite", "assignmentIndicator": "ASSIGNMENT", "searchAllLearningPathsTitle": "Search all learning paths", + "not-in-group-message": "You are not part of a group yet", "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.", - "not-in-group-message": "You are not part of a group yet" + "no-students-found": "This class has no students.", + "no-invitations-found": "You have no pending invitations.", + "no-join-requests-found": "There are no pending join requests for this class.", + "no-classes-found": "You are not yet part of a class.", + "classCreated": "class created!", + "success": "success", + "submitted": "submitted", + "see-submission": "view submission", + "view-submissions": "view submissions", + "valid-username": "please enter a valid username", + "creationFailed": "creation failed, please try again", + "no-assignments": "There are currently no assignments.", + "deadline": "deadline", + "learningObjects": "Learning objects", + "learningPaths": "Learning paths", + "hruid": "HRUID", + "language": "Language", + "version": "Version", + "previewFor": "Preview for ", + "upload": "Upload", + "learningObjectUploadTitle": "Upload a learning object", + "uploadFailed": "Upload failed", + "invalidZip": "This is not a valid zip file.", + "emptyZip": "This zip file is empty", + "missingMetadata": "This learning object is missing a metadata.json file.", + "missingContent": "This learning object is missing a content.* file.", + "open": "open", + "editLearningPath": "Edit learning path", + "newLearningPath": "Create a new learning path", + "saveChanges": "Save changes", + "newLearningObject": "Upload learning object", + "confirmDialogTitle": "Please confirm", + "learningPathDeleteQuery": "Are you sure you want to delete this learning path?", + "learningObjectDeleteQuery": "Are you sure you want to delete this learning object?", + "learningPathCantModifyId": "The HRUID or language of a learning path cannot be modified.", + "error": "Error", + "ownLearningContentTitle": "Own learning content", + "ownLearningContentDescription": "Create and administrate your own learning objects and learning paths. For advanced users only.", + "learningPathNotFound": "This learning path could not be found.", + "emptyLearningPath": "This learning path does not contain any learning objects.", + "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", + "targetAgesMandatory": "Target ages must be specified.", + "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", + "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index b331a168..197fc97f 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -33,6 +33,7 @@ "JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.", "invalidFormat": "Format non valide.", "submitCode": "envoyer", + "submit": "envoyer", "members": "Membres", "themes": "Thèmes", "choose-theme": "Choisis un thème", @@ -68,22 +69,22 @@ "pick-class": "Choisissez une classe", "choose-students": "Sélectionnez des élèves", "create-group": "Créer un groupe", - "class": "classe", + "class": "Classe", "delete": "supprimer", "view-assignment": "Voir le travail", - "code": "code", - "invitations": "invitations", - "createClass": "créer une classe", + "code": "Code", + "invitations": "Invitations", + "createClass": "Créer une classe", "createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.", "classname": "nom de classe", "EnterNameOfClass": "saisir un nom de classe.", "create": "créer", - "sender": "expéditeur", + "sender": "Expéditeur", "nameIsMandatory": "le nom de classe est obligatoire", "onlyUse": "n'utiliser que des lettres, des chiffres, des tirets (-) et des traits de soulignement (_)", "close": "fermer", "copied": "copié!", - "accept": "accepter", + "accept": "Accepter", "deny": "refuser", "sent": "envoyé", "failed": "échoué", @@ -104,16 +105,16 @@ "assignLearningPath": "donner comme tâche", "group": "Groupe", "description": "Description", - "no-submission": "aucune soumission", "submission": "Soumission", "progress": "Progrès", "remove": "supprimer", - "students": "étudiants", - "classJoinRequests": "demandes d'adhésion", - "reject": "rejeter", + "students": "Étudiants", + + "classJoinRequests": "Demandes d'adhésion", + "reject": "Rejeter", "areusure": "Êtes-vous sûr?", "yes": "oui", - "teachers": "enseignants", + "teachers": "Enseignants", "accepted": "acceptée", "rejected": "rejetée", "enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter", @@ -121,5 +122,49 @@ "invite": "inviter", "assignmentIndicator": "DEVOIR", "searchAllLearningPathsTitle": "Rechercher tous les parcours d'apprentissage", - "searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles." + "searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles.", + "no-students-found": "Cette classe n'a pas d'élèves.", + "no-invitations-found": "Vous n'avez aucune invitation en attente.", + "no-join-requests-found": "Il n'y a aucune demande d'adhésion en attente pour cette classe.", + "no-classes-found": "Vous ne faites pas encore partie d'une classe.", + "classCreated": "Classe créée !", + "success": "succès", + "submitted": "soumis", + "see-submission": "voir la soumission", + "view-submissions": "voir les soumissions", + "valid-username": "veuillez entrer un nom d'utilisateur valide", + "creationFailed": "échec de la création, veuillez réessayer", + "no-assignments": "Il n'y a actuellement aucun travail.", + "deadline": "délai", + "learningObjects": "Objets d’apprentissage", + "learningPaths": "Parcours d’apprentissage", + "hruid": "HRUID", + "language": "Langue", + "version": "Version", + "previewFor": "Aperçu de ", + "upload": "Téléverser", + "learningObjectUploadTitle": "Téléverser un objet d’apprentissage", + "uploadFailed": "Échec du téléversement", + "invalidZip": "Ce n’est pas un fichier ZIP valide.", + "emptyZip": "Ce fichier ZIP est vide.", + "missingMetadata": "Il manque un fichier metadata.json à cet objet d’apprentissage.", + "missingContent": "Il manque un fichier content.* à cet objet d’apprentissage.", + "open": "ouvrir", + "editLearningPath": "Modifier le parcours", + "newLearningPath": "Créer un nouveau parcours", + "saveChanges": "Enregistrer les modifications", + "newLearningObject": "Téléverser un objet d’apprentissage", + "confirmDialogTitle": "Veuillez confirmer", + "learningPathDeleteQuery": "Voulez-vous vraiment supprimer ce parcours d’apprentissage ?", + "learningObjectDeleteQuery": "Voulez-vous vraiment supprimer cet objet d’apprentissage ?", + "learningPathCantModifyId": "Le HRUID ou la langue d’un parcours ne peuvent pas être modifiés.", + "error": "Erreur", + "ownLearningContentTitle": "Contenu d’apprentissage personnel", + "ownLearningContentDescription": "Créez et gérez vos propres objets et parcours d’apprentissage. Réservé aux utilisateurs avancés.", + "learningPathNotFound": "Ce parcours d'apprentissage est introuvable.", + "emptyLearningPath": "Ce parcours d'apprentissage ne contient aucun objet d'apprentissage.", + "pathContainsNonExistingLearningObjects": "Au moins un des objets d’apprentissage référencés dans ce chemin n’existe pas.", + "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", + "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", + "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 5aa17930..ece96633 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -33,6 +33,7 @@ "JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.", "invalidFormat": "Ongeldig formaat.", "submitCode": "verzenden", + "submit": "verzenden", "members": "Leden", "themes": "Lesthema's", "choose-theme": "Kies een thema", @@ -68,22 +69,22 @@ "pick-class": "Kies een klas", "choose-students": "Studenten selecteren", "create-group": "Groep aanmaken", - "class": "klas", + "class": "Klas", "delete": "verwijderen", "view-assignment": "Opdracht bekijken", - "code": "code", - "invitations": "uitnodigingen", - "createClass": "klas aanmaken", + "code": "Code", + "invitations": "Uitnodigingen", + "createClass": "Klas aanmaken", "createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.", "classname": "klasnaam", "EnterNameOfClass": "Geef een klasnaam op.", "create": "aanmaken", - "sender": "afzender", + "sender": "Afzender", "nameIsMandatory": "klasnaam is verplicht", "onlyUse": "gebruik enkel letters, cijfers, dashes (-) en underscores (_)", "close": "sluiten", "copied": "gekopieerd!", - "accept": "accepteren", + "accept": "Accepteren", "deny": "weigeren", "sent": "verzonden", "failed": "mislukt", @@ -104,16 +105,15 @@ "assignLearningPath": "Als opdracht geven", "group": "Groep", "description": "Beschrijving", - "no-submission": "geen indiening", "submission": "Indiening", "progress": "Vooruitgang", "remove": "verwijder", - "students": "studenten", - "classJoinRequests": "deelname verzoeken", - "reject": "weiger", + "students": "Studenten", + "classJoinRequests": "Deelname verzoeken", + "reject": "Weiger", "areusure": "Bent u zeker?", "yes": "ja", - "teachers": "leerkrachten", + "teachers": "Leerkrachten", "accepted": "geaccepteerd", "rejected": "geweigerd", "enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in", @@ -121,5 +121,49 @@ "invite": "uitnodigen", "assignmentIndicator": "OPDRACHT", "searchAllLearningPathsTitle": "Alle leerpaden doorzoeken", - "searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken." + "searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken.", + "no-students-found": "Deze klas heeft geen leerlingen.", + "no-invitations-found": "U heeft geen openstaande uitnodigingen.", + "no-join-requests-found": "Er zijn geen openstaande verzoeken om lid te worden van deze klas.", + "no-classes-found": "U maakt nog geen deel uit van een klas.", + "classCreated": "Klas aangemaakt!", + "success": "succes", + "submitted": "ingediend", + "see-submission": "inzending bekijken", + "view-submissions": "inzendingen bekijken", + "valid-username": "voer een geldige gebruikersnaam in", + "creationFailed": "aanmaak mislukt, probeer het opnieuw", + "no-assignments": "Er zijn momenteel geen opdrachten.", + "deadline": "deadline", + "learningObjects": "Leerobjecten", + "learningPaths": "Leerpaden", + "hruid": "HRUID", + "language": "Taal", + "version": "Versie", + "previewFor": "Voorbeeld van ", + "upload": "Uploaden", + "learningObjectUploadTitle": "Leerobject uploaden", + "uploadFailed": "Upload mislukt", + "invalidZip": "Dit is geen geldig zipbestand.", + "emptyZip": "Dit zipbestand is leeg.", + "missingMetadata": "Dit leerobject mist een metadata.json-bestand.", + "missingContent": "Dit leerobject mist een content.*-bestand.", + "open": "openen", + "editLearningPath": "Leerpad bewerken", + "newLearningPath": "Nieuw leerpad aanmaken", + "saveChanges": "Wijzigingen opslaan", + "newLearningObject": "Leerobject uploaden", + "confirmDialogTitle": "Bevestig alstublieft", + "learningPathDeleteQuery": "Weet u zeker dat u dit leerpad wilt verwijderen?", + "learningObjectDeleteQuery": "Weet u zeker dat u dit leerobject wilt verwijderen?", + "learningPathCantModifyId": "De HRUID of taal van een leerpad kan niet worden gewijzigd.", + "error": "Fout", + "ownLearningContentTitle": "Eigen leerinhoud", + "ownLearningContentDescription": "Maak en beheer je eigen leerobjecten en leerpads. Alleen voor gevorderde gebruikers.", + "learningPathNotFound": "Dit leerpad kon niet gevonden worden.", + "emptyLearningPath": "Dit leerpad bevat geen leerobjecten.", + "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", + "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", + "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", + "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties" } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b5315634..3e825557 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,15 +7,20 @@ import * as components from "vuetify/components"; import * as directives from "vuetify/directives"; import i18n from "./i18n/i18n.ts"; +// JSON-editor +import JsonEditorVue from "json-editor-vue"; + // Components import App from "./App.vue"; import router from "./router"; import { aliases, mdi } from "vuetify/iconsets/mdi"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; +import { de, en, fr, nl } from "vuetify/locale"; const app = createApp(App); app.use(router); +app.use(JsonEditorVue, {}); const link = document.createElement("link"); link.rel = "stylesheet"; @@ -32,6 +37,11 @@ const vuetify = createVuetify({ mdi, }, }, + locale: { + locale: i18n.global.locale, + fallback: "en", + messages: { nl, en, de, fr }, + }, }); const queryClient = new QueryClient({ diff --git a/frontend/src/queries/classes.ts b/frontend/src/queries/classes.ts index dca92230..6c452f10 100644 --- a/frontend/src/queries/classes.ts +++ b/frontend/src/queries/classes.ts @@ -15,6 +15,7 @@ import { invalidateAllGroupKeys } from "./groups"; import { invalidateAllSubmissionKeys } from "./submissions"; import type { TeachersResponse } from "@/controllers/teachers"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; +import { studentClassesQueryKey } from "@/queries/students.ts"; const classController = new ClassController(); @@ -171,6 +172,8 @@ export function useClassDeleteStudentMutation(): UseMutationReturnType< await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); + await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, false) }); + await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, true) }); }, }); } diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index 35ed7ae4..20eb210c 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -1,9 +1,16 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); @@ -24,15 +31,15 @@ export function useLearningObjectMetadataQuery( } export function useLearningObjectHTMLQuery( - hruid: MaybeRefOrGetter, - language: MaybeRefOrGetter, - version: MaybeRefOrGetter, + hruid: MaybeRefOrGetter, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], queryFn: async () => { const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; - return learningObjectController.getHTML(hruidVal, languageVal, versionVal); + return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!); }, enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), }); @@ -55,3 +62,49 @@ export function useLearningObjectListForPathQuery( enabled: () => Boolean(toValue(learningPath)), }); } + +export function useLearningObjectListForAdminQuery( + admin: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_OBJECT_KEY, "forAdmin", admin], + queryFn: async () => { + const adminVal = toValue(admin); + return await learningObjectController.getAllAdministratedBy(adminVal!); + }, + enabled: () => toValue(admin) !== undefined, + }); +} + +export function useUploadLearningObjectMutation(): UseMutationReturnType< + LearningObject, + AxiosError, + { learningObjectZip: File }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); + }, + }); +} + +export function useDeleteLearningObjectMutation(): UseMutationReturnType< + LearningObject, + AxiosError, + { hruid: string; language: Language; version: number }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language, version }) => + await learningObjectController.deleteLearningObject(hruid, language, version), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); + }, + }); +} diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts index 1f088c9d..80d59afa 100644 --- a/frontend/src/queries/learning-paths.ts +++ b/frontend/src/queries/learning-paths.ts @@ -1,8 +1,16 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { getLearningPathController } from "@/controllers/controllers"; -import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; +import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; export const LEARNING_PATH_KEY = "learningPath"; const learningPathController = getLearningPathController(); @@ -22,16 +30,69 @@ export function useGetLearningPathQuery( }); } -export function useGetAllLearningPathsByThemeQuery( +export function useGetAllLearningPathsByThemeAndLanguageQuery( theme: MaybeRefOrGetter, + language: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ - queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme], - queryFn: async () => learningPathController.getAllByTheme(toValue(theme)), + queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme, language], + queryFn: async () => learningPathController.getAllByThemeAndLanguage(toValue(theme), toValue(language)), enabled: () => Boolean(toValue(theme)), }); } +export function useGetAllLearningPathsByAdminQuery( + admin: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_PATH_KEY, "getAllByAdmin", admin], + queryFn: async () => learningPathController.getAllByAdminRaw(toValue(admin)!), + enabled: () => Boolean(toValue(admin)), + }); +} + +export function usePostLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { learningPath: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.postLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + +export function usePutLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { learningPath: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.putLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + +export function useDeleteLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { hruid: string; language: Language }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language }) => learningPathController.deleteLearningPath(hruid, language), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + export function useSearchLearningPathQuery( query: MaybeRefOrGetter, language: MaybeRefOrGetter, diff --git a/frontend/src/queries/students.ts b/frontend/src/queries/students.ts index da87d28b..1d6794f1 100644 --- a/frontend/src/queries/students.ts +++ b/frontend/src/queries/students.ts @@ -33,7 +33,7 @@ function studentsQueryKey(full: boolean): [string, boolean] { function studentQueryKey(username: string): [string, string] { return ["student", username]; } -function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { +export function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { return ["student-classes", username, full]; } function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { diff --git a/frontend/src/queries/teachers.ts b/frontend/src/queries/teachers.ts index ab799118..7020b1aa 100644 --- a/frontend/src/queries/teachers.ts +++ b/frontend/src/queries/teachers.ts @@ -10,7 +10,6 @@ import { import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts"; import type { ClassesResponse } from "@/controllers/classes.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; -import type { QuestionsResponse } from "@/controllers/questions.ts"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; import type { AssignmentResponse, AssignmentsResponse } from "@/controllers/assignments"; @@ -38,10 +37,6 @@ function teacherStudentsQueryKey(username: string, full: boolean): [string, stri return ["teacher-students", username, full]; } -function teacherQuestionsQueryKey(username: string, full: boolean): [string, string, boolean] { - return ["teacher-questions", username, full]; -} - export function teacherClassJoinRequests(classId: string): [string, string] { return ["teacher-class-join-requests", classId]; } @@ -96,17 +91,6 @@ export function useTeacherStudentsQuery( }); } -export function useTeacherQuestionsQuery( - username: MaybeRefOrGetter, - full: MaybeRefOrGetter = false, -): UseQueryReturnType { - return useQuery({ - queryKey: computed(() => teacherQuestionsQueryKey(toValue(username)!, toValue(full))), - queryFn: async () => teacherController.getQuestions(toValue(username)!, toValue(full)), - enabled: () => Boolean(toValue(username)), - }); -} - export function useTeacherJoinRequestsQuery( username: MaybeRefOrGetter, classId: MaybeRefOrGetter, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 359eab1a..fa3d6e05 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,8 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue"; import SingleTheme from "@/views/SingleTheme.vue"; import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import authService from "@/services/auth/auth-service"; +import OwnLearningContentPage from "@/views/own-learning-content/OwnLearningContentPage.vue"; +import { allowRedirect, Redirect } from "@/utils/redirect.ts"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -105,6 +107,12 @@ const router = createRouter({ component: SingleDiscussion, meta: { requiresAuth: true }, }, + { + path: "/my-content", + name: "OwnLearningContentPage", + component: OwnLearningContentPage, + meta: { requiresAuth: true }, + }, { path: "/learningPath", children: [ @@ -143,7 +151,11 @@ router.beforeEach(async (to, _from, next) => { // Verify if user is logged in before accessing certain routes if (to.meta.requiresAuth) { if (!authService.isLoggedIn.value && !(await authService.loadUser())) { - next("/login"); + const path = to.fullPath; + if (allowRedirect(path)) { + localStorage.setItem(Redirect.AFTER_LOGIN_KEY, path); + } + next(Redirect.LOGIN); } else { next(); } diff --git a/frontend/src/utils/redirect.ts b/frontend/src/utils/redirect.ts new file mode 100644 index 00000000..f3ec0e75 --- /dev/null +++ b/frontend/src/utils/redirect.ts @@ -0,0 +1,12 @@ +export enum Redirect { + AFTER_LOGIN_KEY = "redirectAfterLogin", + HOME = "/user", + LOGIN = "/login", + ROOT = "/", +} + +const NOT_ALLOWED_REDIRECTS = new Set([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]); + +export function allowRedirect(path: string): boolean { + return !NOT_ALLOWED_REDIRECTS.has(path as Redirect); +} diff --git a/frontend/src/views/CallbackPage.vue b/frontend/src/views/CallbackPage.vue index cd004eae..d4d300e4 100644 --- a/frontend/src/views/CallbackPage.vue +++ b/frontend/src/views/CallbackPage.vue @@ -3,6 +3,7 @@ import { useI18n } from "vue-i18n"; import { onMounted, ref, type Ref } from "vue"; import auth from "../services/auth/auth-service.ts"; + import { Redirect } from "@/utils/redirect.ts"; const { t } = useI18n(); @@ -10,10 +11,20 @@ const errorMessage: Ref = ref(null); + async function redirectPage(): Promise { + const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY); + if (redirectUrl) { + localStorage.removeItem(Redirect.AFTER_LOGIN_KEY); + await router.replace(redirectUrl); + } else { + await router.replace(Redirect.HOME); + } + } + onMounted(async () => { try { await auth.handleLoginCallback(); - await router.replace("/user"); // Redirect to theme page + await redirectPage(); } catch (error) { errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; } diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue index 08ce44c1..c0dcffaf 100644 --- a/frontend/src/views/HomePage.vue +++ b/frontend/src/views/HomePage.vue @@ -28,7 +28,7 @@ alt="Dwengo logo" style="align-self: center" /> -

{{ t("homeTitle") }}

+

{{ t("homeTitle") }}

{{ t("homeIntroduction1") }}

@@ -84,7 +84,10 @@
- + diff --git a/frontend/src/views/SingleTheme.vue b/frontend/src/views/SingleTheme.vue index 6924cc1c..1cd9afab 100644 --- a/frontend/src/views/SingleTheme.vue +++ b/frontend/src/views/SingleTheme.vue @@ -2,10 +2,11 @@ import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue"; - import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts"; + import { useGetAllLearningPathsByThemeAndLanguageQuery } from "@/queries/learning-paths.ts"; import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import { useThemeQuery } from "@/queries/themes.ts"; + import type { Language } from "@/data-objects/language"; const props = defineProps<{ theme: string }>(); @@ -16,7 +17,10 @@ const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme)); - const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme); + const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeAndLanguageQuery( + () => props.theme, + () => locale.value as Language, + ); const { t } = useI18n(); const searchFilter = ref(""); @@ -31,13 +35,14 @@