Merge branch 'dev' into test/linking
This commit is contained in:
		
						commit
						906eb36fec
					
				
					 153 changed files with 3579 additions and 3184 deletions
				
			
		|  | @ -1,4 +1,10 @@ | |||
| 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'; | ||||
| 
 | ||||
| interface FrontendIdpConfig { | ||||
|     authority: string; | ||||
|  | @ -15,6 +21,8 @@ interface FrontendAuthConfig { | |||
| const SCOPE = 'openid profile email'; | ||||
| const RESPONSE_TYPE = 'code'; | ||||
| 
 | ||||
| const logger = getLogger(); | ||||
| 
 | ||||
| export function getFrontendAuthConfig(): FrontendAuthConfig { | ||||
|     return { | ||||
|         student: { | ||||
|  | @ -31,3 +39,24 @@ export function getFrontendAuthConfig(): FrontendAuthConfig { | |||
|         }, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||
|     const auth = req.auth; | ||||
|     if (!auth) { | ||||
|         throw new UnauthorizedException('Cannot say hello when not authenticated.'); | ||||
|     } | ||||
|     const userData = { | ||||
|         id: auth.username, | ||||
|         username: auth.username, | ||||
|         firstName: auth.firstName ?? '', | ||||
|         lastName: auth.lastName ?? '', | ||||
|     }; | ||||
|     if (auth.accountType === 'student') { | ||||
|         await createOrUpdateStudent(userData); | ||||
|         logger.debug(`Synchronized student ${userData.username} with IDP`); | ||||
|     } else { | ||||
|         await createOrUpdateTeacher(userData); | ||||
|         logger.debug(`Synchronized teacher ${userData.username} with IDP`); | ||||
|     } | ||||
|     res.status(200).send({ message: 'Welcome!' }); | ||||
| } | ||||
|  |  | |||
|  | @ -69,8 +69,8 @@ export async function getAllGroupsHandler(req: Request, res: Response): Promise< | |||
| export async function createGroupHandler(req: Request, res: Response): Promise<void> { | ||||
|     const classid = req.params.classid; | ||||
|     const assignmentId = Number(req.params.assignmentid); | ||||
| 
 | ||||
|     requireFields({ classid, assignmentId }); | ||||
|     const members = req.body.members; | ||||
|     requireFields({ classid, assignmentId, members }); | ||||
| 
 | ||||
|     if (isNaN(assignmentId)) { | ||||
|         throw new BadRequestException('Assignment id must be a number'); | ||||
|  |  | |||
|  | @ -3,13 +3,10 @@ import { themes } from '../data/themes.js'; | |||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| import learningPathService from '../services/learning-paths/learning-path-service.js'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { | ||||
|     PersonalizationTarget, | ||||
|     personalizedForGroup, | ||||
|     personalizedForStudent, | ||||
| } from '../services/learning-paths/learning-path-personalization-util.js'; | ||||
| 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'; | ||||
| 
 | ||||
| /** | ||||
|  * Fetch learning paths based on query parameters. | ||||
|  | @ -20,20 +17,20 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi | |||
|     const searchQuery = req.query.search as string; | ||||
|     const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|     const forStudent = req.query.forStudent as string; | ||||
|     const forGroupNo = req.query.forGroup as string; | ||||
|     const assignmentNo = req.query.assignmentNo as string; | ||||
|     const classId = req.query.classId as string; | ||||
| 
 | ||||
|     let personalizationTarget: PersonalizationTarget | undefined; | ||||
|     let forGroup: Group | undefined; | ||||
| 
 | ||||
|     if (forStudent) { | ||||
|         personalizationTarget = await personalizedForStudent(forStudent); | ||||
|     } else if (forGroupNo) { | ||||
|     if (forGroupNo) { | ||||
|         if (!assignmentNo || !classId) { | ||||
|             throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); | ||||
|         } | ||||
|         personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo)); | ||||
|         const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); | ||||
|         if (assignment) { | ||||
|             forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let hruidList; | ||||
|  | @ -48,18 +45,13 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi | |||
|             throw new NotFoundException(`Theme "${themeKey}" not found.`); | ||||
|         } | ||||
|     } else if (searchQuery) { | ||||
|         const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget); | ||||
|         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(', ')}`, | ||||
|         personalizationTarget | ||||
|     ); | ||||
|     const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); | ||||
|     res.json(learningPaths.data); | ||||
| } | ||||
|  |  | |||
|  | @ -17,15 +17,18 @@ export async function getSubmissionsHandler(req: Request, res: Response): Promis | |||
|     const lang = languageMap[req.query.language as string] || Language.Dutch; | ||||
|     const version = parseInt(req.query.version as string) ?? 1; | ||||
| 
 | ||||
|     const submissions = await getSubmissionsForLearningObjectAndAssignment( | ||||
|     const forGroup = req.query.forGroup as string | undefined; | ||||
| 
 | ||||
|     const submissions: SubmissionDTO[] = await getSubmissionsForLearningObjectAndAssignment( | ||||
|         loHruid, | ||||
|         lang, | ||||
|         version, | ||||
|         req.query.classId as string, | ||||
|         parseInt(req.query.assignmentId as string) | ||||
|         parseInt(req.query.assignmentId as string), | ||||
|         forGroup ? parseInt(forGroup) : undefined | ||||
|     ); | ||||
| 
 | ||||
|     res.json(submissions); | ||||
|     res.json({ submissions }); | ||||
| } | ||||
| 
 | ||||
| export async function getSubmissionHandler(req: Request, res: Response): Promise<void> { | ||||
|  |  | |||
|  | @ -40,7 +40,7 @@ export async function updateInvitationHandler(req: Request, res: Response): Prom | |||
|     const sender = req.body.sender; | ||||
|     const receiver = req.body.receiver; | ||||
|     const classId = req.body.class; | ||||
|     req.body.accepted = req.body.accepted !== 'false'; | ||||
|     req.body.accepted = req.body.accepted !== false; | ||||
|     requireFields({ sender, receiver, classId }); | ||||
| 
 | ||||
|     const data = req.body as TeacherInvitationData; | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ import { Class } from '../../entities/classes/class.entity.js'; | |||
| 
 | ||||
| export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | ||||
|     public async findByClassAndId(within: Class, id: number): Promise<Assignment | null> { | ||||
|         return this.findOne({ within: within, id: id }); | ||||
|         return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] }); | ||||
|     } | ||||
|     public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> { | ||||
|         return this.findOne({ within: { classId: withinClass }, id: id }); | ||||
|  | @ -23,7 +23,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | |||
|         }); | ||||
|     } | ||||
|     public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { | ||||
|         return this.findAll({ where: { within: within } }); | ||||
|         return this.findAll({ where: { within: within }, populate: ['groups', 'groups.members'] }); | ||||
|     } | ||||
|     public async deleteByClassAndId(within: Class, id: number): Promise<void> { | ||||
|         return this.deleteWhere({ within: within, id: id }); | ||||
|  |  | |||
|  | @ -61,32 +61,30 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | |||
| 
 | ||||
|     /** | ||||
|      * Looks up all submissions for the given learning object which were submitted as part of the given assignment. | ||||
|      * When forStudentUsername is set, only the submissions of the given user's group are shown. | ||||
|      */ | ||||
|     public async findAllSubmissionsForLearningObjectAndAssignment( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         assignment: Assignment, | ||||
|         forStudentUsername?: string | ||||
|     ): Promise<Submission[]> { | ||||
|         const onBehalfOf = forStudentUsername | ||||
|             ? { | ||||
|                   assignment, | ||||
|                   members: { | ||||
|                       $some: { | ||||
|                           username: forStudentUsername, | ||||
|                       }, | ||||
|                   }, | ||||
|               } | ||||
|             : { | ||||
|                   assignment, | ||||
|               }; | ||||
| 
 | ||||
|     public async findAllSubmissionsForLearningObjectAndAssignment(loId: LearningObjectIdentifier, assignment: Assignment): Promise<Submission[]> { | ||||
|         return this.findAll({ | ||||
|             where: { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|                 learningObjectLanguage: loId.language, | ||||
|                 learningObjectVersion: loId.version, | ||||
|                 onBehalfOf, | ||||
|                 onBehalfOf: { | ||||
|                     assignment, | ||||
|                 }, | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Looks up all submissions for the given learning object which were submitted by the given group | ||||
|      */ | ||||
|     public async findAllSubmissionsForLearningObjectAndGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission[]> { | ||||
|         return this.findAll({ | ||||
|             where: { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|                 learningObjectLanguage: loId.language, | ||||
|                 learningObjectVersion: loId.version, | ||||
|                 onBehalfOf: group, | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
|  |  | |||
|  | @ -1,6 +1,10 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { LearningPath } from '../../entities/content/learning-path.entity.js'; | ||||
| 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<LearningPath> { | ||||
|     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|  | @ -23,4 +27,27 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath> | |||
|             populate: ['nodes', 'nodes.transitions'], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     public createNode(nodeData: RequiredEntityData<LearningPathNode>): LearningPathNode { | ||||
|         return this.em.create(LearningPathNode, nodeData); | ||||
|     } | ||||
| 
 | ||||
|     public createTransition(transitionData: RequiredEntityData<LearningPathTransition>): LearningPathTransition { | ||||
|         return this.em.create(LearningPathTransition, transitionData); | ||||
|     } | ||||
| 
 | ||||
|     public async saveLearningPathNodesAndTransitions( | ||||
|         path: LearningPath, | ||||
|         nodes: LearningPathNode[], | ||||
|         transitions: LearningPathTransition[], | ||||
|         options?: { preventOverwrite?: boolean } | ||||
|     ): Promise<void> { | ||||
|         if (options?.preventOverwrite && (await this.findOne(path))) { | ||||
|             throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); | ||||
|         } | ||||
|         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))); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ export class Assignment { | |||
|     }) | ||||
|     within!: Class; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'number', autoincrement: true }) | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     id?: number; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|  | @ -35,5 +35,5 @@ export class Assignment { | |||
|         entity: () => Group, | ||||
|         mappedBy: 'assignment', | ||||
|     }) | ||||
|     groups!: Collection<Group>; | ||||
|     groups: Collection<Group> = new Collection<Group>(this); | ||||
| } | ||||
|  |  | |||
|  | @ -7,17 +7,23 @@ import { GroupRepository } from '../../data/assignments/group-repository.js'; | |||
|     repository: () => GroupRepository, | ||||
| }) | ||||
| export class Group { | ||||
|     /* | ||||
|      WARNING: Don't move the definition of groupNumber! If it does not come before the definition of assignment, | ||||
|      creating groups fails because of a MikroORM bug! | ||||
|      */ | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     groupNumber?: number; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => Assignment, | ||||
|         primary: true, | ||||
|     }) | ||||
|     assignment!: Assignment; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     groupNumber?: number; | ||||
| 
 | ||||
|     @ManyToMany({ | ||||
|         entity: () => Student, | ||||
|         owner: true, | ||||
|         inversedBy: 'groups', | ||||
|     }) | ||||
|     members!: Collection<Student>; | ||||
|     members: Collection<Student> = new Collection<Student>(this); | ||||
| } | ||||
|  |  | |||
|  | @ -6,6 +6,9 @@ import { Language } from '@dwengo-1/common/util/language'; | |||
| 
 | ||||
| @Entity({ repository: () => SubmissionRepository }) | ||||
| export class Submission { | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     submissionNumber?: number; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|  | @ -15,12 +18,9 @@ export class Submission { | |||
|     }) | ||||
|     learningObjectLanguage!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'numeric' }) | ||||
|     @PrimaryKey({ type: 'numeric', autoincrement: false }) | ||||
|     learningObjectVersion = 1; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     submissionNumber?: number; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => Group, | ||||
|     }) | ||||
|  |  | |||
|  | @ -14,9 +14,9 @@ export class Class { | |||
|     @Property({ type: 'string' }) | ||||
|     displayName!: string; | ||||
| 
 | ||||
|     @ManyToMany(() => Teacher) | ||||
|     @ManyToMany({ entity: () => Teacher, owner: true, inversedBy: 'classes' }) | ||||
|     teachers!: Collection<Teacher>; | ||||
| 
 | ||||
|     @ManyToMany(() => Student) | ||||
|     @ManyToMany({ entity: () => Student, owner: true, inversedBy: 'classes' }) | ||||
|     students!: Collection<Student>; | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { ArrayType, 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'; | ||||
|  | @ -42,7 +42,7 @@ export class LearningObject { | |||
|     @Property({ type: 'array' }) | ||||
|     keywords: string[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'array', nullable: true }) | ||||
|     @Property({ type: new ArrayType((i) => Number(i)), nullable: true }) | ||||
|     targetAges?: number[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|  |  | |||
|  | @ -1,16 +1,16 @@ | |||
| import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | ||||
| import { 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'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class LearningPathNode { | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     nodeNumber?: number; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => LearningPath, primary: true }) | ||||
|     learningPath!: Rel<LearningPath>; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     nodeNumber!: number; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|  | @ -27,7 +27,7 @@ export class LearningPathNode { | |||
|     startNode!: boolean; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) | ||||
|     transitions: LearningPathTransition[] = []; | ||||
|     transitions!: Collection<LearningPathTransition>; | ||||
| 
 | ||||
|     @Property({ length: 3 }) | ||||
|     createdAt: Date = new Date(); | ||||
|  |  | |||
|  | @ -3,12 +3,12 @@ import { LearningPathNode } from './learning-path-node.entity.js'; | |||
| 
 | ||||
| @Entity() | ||||
| export class LearningPathTransition { | ||||
|     @ManyToOne({ entity: () => LearningPathNode, primary: true }) | ||||
|     node!: Rel<LearningPathNode>; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'numeric' }) | ||||
|     transitionNumber!: number; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => LearningPathNode, primary: true }) | ||||
|     node!: Rel<LearningPathNode>; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     condition!: string; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { 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'; | ||||
|  | @ -25,5 +25,5 @@ export class LearningPath { | |||
|     image: Buffer | null = null; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) | ||||
|     nodes: LearningPathNode[] = []; | ||||
|     nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this); | ||||
| } | ||||
|  |  | |||
|  | @ -8,9 +8,9 @@ import { StudentRepository } from '../../data/users/student-repository.js'; | |||
|     repository: () => StudentRepository, | ||||
| }) | ||||
| export class Student extends User { | ||||
|     @ManyToMany(() => Class) | ||||
|     @ManyToMany({ entity: () => Class, mappedBy: 'students' }) | ||||
|     classes!: Collection<Class>; | ||||
| 
 | ||||
|     @ManyToMany(() => Group) | ||||
|     groups!: Collection<Group>; | ||||
|     @ManyToMany({ entity: () => Group, mappedBy: 'members' }) | ||||
|     groups: Collection<Group> = new Collection<Group>(this); | ||||
| } | ||||
|  |  | |||
|  | @ -5,6 +5,6 @@ import { TeacherRepository } from '../../data/users/teacher-repository.js'; | |||
| 
 | ||||
| @Entity({ repository: () => TeacherRepository }) | ||||
| export class Teacher extends User { | ||||
|     @ManyToMany(() => Class) | ||||
|     @ManyToMany({ entity: () => Class, mappedBy: 'teachers' }) | ||||
|     classes!: Collection<Class>; | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,9 @@ | |||
| import { HasStatusCode } from './has-status-code'; | ||||
| 
 | ||||
| /** | ||||
|  * Exceptions which are associated with a HTTP error code. | ||||
|  */ | ||||
| export abstract class ExceptionWithHttpState extends Error { | ||||
| export abstract class ExceptionWithHttpState extends Error implements HasStatusCode { | ||||
|     constructor( | ||||
|         public status: number, | ||||
|         public error: string | ||||
|  |  | |||
							
								
								
									
										6
									
								
								backend/src/exceptions/has-status-code.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								backend/src/exceptions/has-status-code.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| export interface HasStatusCode { | ||||
|     status: number; | ||||
| } | ||||
| export function hasStatusCode(err: unknown): err is HasStatusCode { | ||||
|     return typeof err === 'object' && err !== null && 'status' in err && typeof (err as HasStatusCode)?.status === 'number'; | ||||
| } | ||||
							
								
								
									
										12
									
								
								backend/src/exceptions/server-error-exception.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								backend/src/exceptions/server-error-exception.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| import { ExceptionWithHttpState } from './exception-with-http-state.js'; | ||||
| 
 | ||||
| /** | ||||
|  * Exception for HTTP 500 Internal Server Error | ||||
|  */ | ||||
| export class ServerErrorException extends ExceptionWithHttpState { | ||||
|     status = 500; | ||||
| 
 | ||||
|     constructor(message = 'Internal server error, something went wrong') { | ||||
|         super(500, message); | ||||
|     } | ||||
| } | ||||
|  | @ -1,18 +1,14 @@ | |||
| import { languageMap } from '@dwengo-1/common/util/language'; | ||||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| import { Assignment } from '../entities/assignments/assignment.entity.js'; | ||||
| import { Class } from '../entities/classes/class.entity.js'; | ||||
| import { getLogger } from '../logging/initalize.js'; | ||||
| import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { mapToGroupDTO } from './group.js'; | ||||
| import { getAssignmentRepository } from '../data/repositories.js'; | ||||
| 
 | ||||
| export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTO { | ||||
| export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTOId { | ||||
|     return { | ||||
|         id: assignment.id!, | ||||
|         within: assignment.within.classId!, | ||||
|         title: assignment.title, | ||||
|         description: assignment.description, | ||||
|         learningPath: assignment.learningPathHruid, | ||||
|         language: assignment.learningPathLanguage, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
|  | @ -24,19 +20,17 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { | |||
|         description: assignment.description, | ||||
|         learningPath: assignment.learningPathHruid, | ||||
|         language: assignment.learningPathLanguage, | ||||
|         // Groups: assignment.groups.map(mapToGroupDTO),
 | ||||
|         groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assignment { | ||||
|     const assignment = new Assignment(); | ||||
|     assignment.title = assignmentData.title; | ||||
|     assignment.description = assignmentData.description; | ||||
|     assignment.learningPathHruid = assignmentData.learningPath; | ||||
|     assignment.learningPathLanguage = languageMap[assignmentData.language] || FALLBACK_LANG; | ||||
|     assignment.within = cls; | ||||
| 
 | ||||
|     getLogger().debug(assignment); | ||||
| 
 | ||||
|     return assignment; | ||||
|     return getAssignmentRepository().create({ | ||||
|         within: cls, | ||||
|         title: assignmentData.title, | ||||
|         description: assignmentData.description, | ||||
|         learningPathHruid: assignmentData.learningPath, | ||||
|         learningPathLanguage: languageMap[assignmentData.language], | ||||
|         groups: [], | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,14 +1,12 @@ | |||
| import { Group } from '../entities/assignments/group.entity.js'; | ||||
| import { mapToAssignment } from './assignment.js'; | ||||
| import { mapToStudent } from './student.js'; | ||||
| import { mapToAssignmentDTO } from './assignment.js'; | ||||
| import { mapToStudentDTO } from './student.js'; | ||||
| import { GroupDTO } from '@dwengo-1/common/interfaces/group'; | ||||
| import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; | ||||
| import { getGroupRepository } from '../data/repositories.js'; | ||||
| import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { Class } from '../entities/classes/class.entity.js'; | ||||
| import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | ||||
| import { mapToClassDTO } from './class.js'; | ||||
| 
 | ||||
| export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { | ||||
|     const assignmentDto = groupDto.assignment as AssignmentDTO; | ||||
|  | @ -20,18 +18,18 @@ export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { | |||
|     }); | ||||
| } | ||||
| 
 | ||||
| export function mapToGroupDTO(group: Group): GroupDTO { | ||||
| export function mapToGroupDTO(group: Group, cls: Class): GroupDTO { | ||||
|     return { | ||||
|         class: mapToClassDTO(group.assignment.within), | ||||
|         assignment: mapToAssignmentDTO(group.assignment), | ||||
|         class: cls.classId!, | ||||
|         assignment: group.assignment.id!, | ||||
|         groupNumber: group.groupNumber!, | ||||
|         members: group.members.map(mapToStudentDTO), | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export function mapToGroupDTOId(group: Group): GroupDTO { | ||||
| export function mapToGroupDTOId(group: Group, cls: Class): GroupDTOId { | ||||
|     return { | ||||
|         class: group.assignment.within.classId!, | ||||
|         class: cls.classId!, | ||||
|         assignment: group.assignment.id!, | ||||
|         groupNumber: group.groupNumber!, | ||||
|     }; | ||||
|  |  | |||
|  | @ -31,7 +31,7 @@ export function mapToQuestionDTO(question: Question): QuestionDTO { | |||
|         learningObjectIdentifier, | ||||
|         sequenceNumber: question.sequenceNumber!, | ||||
|         author: mapToStudentDTO(question.author), | ||||
|         inGroup: mapToGroupDTOId(question.inGroup), | ||||
|         inGroup: mapToGroupDTOId(question.inGroup, question.inGroup.assignment?.within), | ||||
|         timestamp: question.timestamp.toISOString(), | ||||
|         content: question.content, | ||||
|     }; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { Submission } from '../entities/assignments/submission.entity.js'; | ||||
| import { mapToGroupDTO } from './group.js'; | ||||
| import { mapToGroupDTOId } from './group.js'; | ||||
| import { mapToStudentDTO } from './student.js'; | ||||
| import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; | ||||
| import { getSubmissionRepository } from '../data/repositories.js'; | ||||
|  | @ -13,11 +13,10 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { | |||
|             language: submission.learningObjectLanguage, | ||||
|             version: submission.learningObjectVersion, | ||||
|         }, | ||||
| 
 | ||||
|         submissionNumber: submission.submissionNumber, | ||||
|         submitter: mapToStudentDTO(submission.submitter), | ||||
|         time: submission.submissionTime, | ||||
|         group: mapToGroupDTO(submission.onBehalfOf), | ||||
|         group: submission.onBehalfOf ? mapToGroupDTOId(submission.onBehalfOf, submission.onBehalfOf.assignment.within) : undefined, | ||||
|         content: submission.content, | ||||
|     }; | ||||
| } | ||||
|  |  | |||
|  | @ -48,14 +48,14 @@ const idpConfigs = { | |||
| const verifyJwtToken = expressjwt({ | ||||
|     secret: async (_: express.Request, token: jwt.Jwt | undefined) => { | ||||
|         if (!token?.payload || !(token.payload as JwtPayload).iss) { | ||||
|             throw new Error('Invalid token'); | ||||
|             throw new UnauthorizedException('Invalid token.'); | ||||
|         } | ||||
| 
 | ||||
|         const issuer = (token.payload as JwtPayload).iss; | ||||
| 
 | ||||
|         const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); | ||||
|         if (!idpConfig) { | ||||
|             throw new Error('Issuer not accepted.'); | ||||
|             throw new UnauthorizedException('Issuer not accepted.'); | ||||
|         } | ||||
| 
 | ||||
|         const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); | ||||
|  |  | |||
|  | @ -1,15 +1,15 @@ | |||
| import { NextFunction, Request, Response } from 'express'; | ||||
| import { getLogger, Logger } from '../../logging/initalize.js'; | ||||
| import { ExceptionWithHttpState } from '../../exceptions/exception-with-http-state.js'; | ||||
| import { hasStatusCode } from '../../exceptions/has-status-code.js'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
| export function errorHandler(err: unknown, _req: Request, res: Response, _: NextFunction): void { | ||||
|     if (err instanceof ExceptionWithHttpState) { | ||||
|     if (hasStatusCode(err)) { | ||||
|         logger.warn(`An error occurred while handling a request: ${err} (-> HTTP ${err.status})`); | ||||
|         res.status(err.status).json(err); | ||||
|     } else { | ||||
|         logger.error(`Unexpected error occurred while handing a request: ${JSON.stringify(err)}`); | ||||
|         logger.error(`Unexpected error occurred while handing a request: ${(err as { stack: string })?.stack ?? JSON.stringify(err)}`); | ||||
|         res.status(500).json(err); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import express from 'express'; | ||||
| import { getFrontendAuthConfig } from '../controllers/auth.js'; | ||||
| import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; | ||||
| import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; | ||||
| const router = express.Router(); | ||||
| 
 | ||||
|  | @ -23,4 +23,6 @@ router.get('/testTeachersOnly', teachersOnly, (_req, res) => { | |||
|     res.json({ message: 'If you see this, you should be a teacher!' }); | ||||
| }); | ||||
| 
 | ||||
| router.post('/hello', authenticatedOnly, postHelloHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ const router = express.Router({ mergeParams: true }); | |||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getSubmissionsHandler); | ||||
| 
 | ||||
| router.post('/:id', createSubmissionHandler); | ||||
| router.post('/', createSubmissionHandler); | ||||
| 
 | ||||
| // Information about an submission with id 'id'
 | ||||
| router.get('/:id', getSubmissionHandler); | ||||
|  |  | |||
|  | @ -34,6 +34,6 @@ router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); | |||
| router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); | ||||
| 
 | ||||
| // Invitations to other classes a teacher received
 | ||||
| router.get('/invitations', invitationRouter); | ||||
| router.use('/invitations', invitationRouter); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { | ||||
|     getAssignmentRepository, | ||||
|     getClassRepository, | ||||
|  | @ -16,6 +16,8 @@ import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; | |||
| import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; | ||||
| import { EntityDTO } from '@mikro-orm/core'; | ||||
| import { putObject } from './service-helper.js'; | ||||
| import { fetchStudents } from './students.js'; | ||||
| import { ServerErrorException } from '../exceptions/server-error-exception.js'; | ||||
| 
 | ||||
| export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> { | ||||
|     const classRepository = getClassRepository(); | ||||
|  | @ -35,7 +37,7 @@ export async function fetchAssignment(classid: string, assignmentNumber: number) | |||
|     return assignment; | ||||
| } | ||||
| 
 | ||||
| export async function getAllAssignments(classid: string, full: boolean): Promise<AssignmentDTO[]> { | ||||
| export async function getAllAssignments(classid: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | ||||
|     const cls = await fetchClass(classid); | ||||
| 
 | ||||
|     const assignmentRepository = getAssignmentRepository(); | ||||
|  | @ -51,13 +53,39 @@ export async function getAllAssignments(classid: string, full: boolean): Promise | |||
| export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise<AssignmentDTO> { | ||||
|     const cls = await fetchClass(classid); | ||||
| 
 | ||||
|     const assignment = mapToAssignment(assignmentData, cls); | ||||
| 
 | ||||
|     const assignmentRepository = getAssignmentRepository(); | ||||
|     const newAssignment = assignmentRepository.create(assignment); | ||||
|     await assignmentRepository.save(newAssignment, { preventOverwrite: true }); | ||||
|     const assignment = mapToAssignment(assignmentData, cls); | ||||
|     await assignmentRepository.save(assignment); | ||||
| 
 | ||||
|     return mapToAssignmentDTO(newAssignment); | ||||
|     if (assignmentData.groups) { | ||||
|         /* | ||||
|         For some reason when trying to add groups, it does not work when using the original assignment variable.  | ||||
|         The assignment needs to be refetched in order for it to work. | ||||
|         */ | ||||
| 
 | ||||
|         const assignmentCopy = await assignmentRepository.findByClassAndId(cls, assignment.id!); | ||||
| 
 | ||||
|         if (assignmentCopy === null) { | ||||
|             throw new ServerErrorException('Something has gone horribly wrong. Could not find newly added assignment which is needed to add groups.'); | ||||
|         } | ||||
| 
 | ||||
|         const groupRepository = getGroupRepository(); | ||||
| 
 | ||||
|         (assignmentData.groups as string[][]).forEach(async (memberUsernames) => { | ||||
|             const members = await fetchStudents(memberUsernames); | ||||
| 
 | ||||
|             const newGroup = groupRepository.create({ | ||||
|                 assignment: assignmentCopy, | ||||
|                 members: members, | ||||
|             }); | ||||
|             await groupRepository.save(newGroup); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /* Need to refetch the assignment here again such that the groups are added. */ | ||||
|     const assignmentWithGroups = await fetchAssignment(classid, assignment.id!); | ||||
| 
 | ||||
|     return mapToAssignmentDTO(assignmentWithGroups); | ||||
| } | ||||
| 
 | ||||
| export async function getAssignment(classid: string, id: number): Promise<AssignmentDTO> { | ||||
|  |  | |||
|  | @ -1,13 +1,14 @@ | |||
| import { EntityDTO } from '@mikro-orm/core'; | ||||
| import { getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js'; | ||||
| import { getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; | ||||
| import { Group } from '../entities/assignments/group.entity.js'; | ||||
| import { mapToGroupDTO, mapToShallowGroupDTO } from '../interfaces/group.js'; | ||||
| import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; | ||||
| import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; | ||||
| import { GroupDTO } from '@dwengo-1/common/interfaces/group'; | ||||
| import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; | ||||
| import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; | ||||
| import { fetchAssignment } from './assignments.js'; | ||||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||
| import { putObject } from './service-helper.js'; | ||||
| import { fetchStudents } from './students.js'; | ||||
| 
 | ||||
| export async function fetchGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<Group> { | ||||
|     const assignment = await fetchAssignment(classId, assignmentNumber); | ||||
|  | @ -24,7 +25,7 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou | |||
| 
 | ||||
| export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { | ||||
|     const group = await fetchGroup(classId, assignmentNumber, groupNumber); | ||||
|     return mapToGroupDTO(group); | ||||
|     return mapToGroupDTO(group, group.assignment.within); | ||||
| } | ||||
| 
 | ||||
| export async function putGroup( | ||||
|  | @ -37,7 +38,7 @@ export async function putGroup( | |||
| 
 | ||||
|     await putObject<Group>(group, groupData, getGroupRepository()); | ||||
| 
 | ||||
|     return mapToGroupDTO(group); | ||||
|     return mapToGroupDTO(group, group.assignment.within); | ||||
| } | ||||
| 
 | ||||
| export async function deleteGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { | ||||
|  | @ -47,7 +48,7 @@ export async function deleteGroup(classId: string, assignmentNumber: number, gro | |||
|     const groupRepository = getGroupRepository(); | ||||
|     await groupRepository.deleteByAssignmentAndGroupNumber(assignment, groupNumber); | ||||
| 
 | ||||
|     return mapToGroupDTO(group); | ||||
|     return mapToGroupDTO(group, assignment.within); | ||||
| } | ||||
| 
 | ||||
| export async function getExistingGroupFromGroupDTO(groupData: GroupDTO): Promise<Group> { | ||||
|  | @ -59,12 +60,8 @@ export async function getExistingGroupFromGroupDTO(groupData: GroupDTO): Promise | |||
| } | ||||
| 
 | ||||
| export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise<GroupDTO> { | ||||
|     const studentRepository = getStudentRepository(); | ||||
| 
 | ||||
|     const memberUsernames = (groupData.members as string[]) || []; | ||||
|     const members = (await Promise.all([...memberUsernames].map(async (id) => studentRepository.findByUsername(id)))).filter( | ||||
|         (student) => student !== null | ||||
|     ); | ||||
|     const members = await fetchStudents(memberUsernames); | ||||
| 
 | ||||
|     const assignment = await fetchAssignment(classid, assignmentNumber); | ||||
| 
 | ||||
|  | @ -73,22 +70,23 @@ export async function createGroup(groupData: GroupDTO, classid: string, assignme | |||
|         assignment: assignment, | ||||
|         members: members, | ||||
|     }); | ||||
| 
 | ||||
|     await groupRepository.save(newGroup); | ||||
| 
 | ||||
|     return mapToGroupDTO(newGroup); | ||||
|     return mapToGroupDTO(newGroup, newGroup.assignment.within); | ||||
| } | ||||
| 
 | ||||
| export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise<GroupDTO[]> { | ||||
| export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> { | ||||
|     const assignment = await fetchAssignment(classId, assignmentNumber); | ||||
| 
 | ||||
|     const groupRepository = getGroupRepository(); | ||||
|     const groups = await groupRepository.findAllGroupsForAssignment(assignment); | ||||
| 
 | ||||
|     if (full) { | ||||
|         return groups.map(mapToGroupDTO); | ||||
|         return groups.map((group) => mapToGroupDTO(group, assignment.within)); | ||||
|     } | ||||
| 
 | ||||
|     return groups.map(mapToShallowGroupDTO); | ||||
|     return groups.map((group) => mapToGroupDTOId(group, assignment.within)); | ||||
| } | ||||
| 
 | ||||
| export async function getGroupSubmissions( | ||||
|  |  | |||
|  | @ -8,12 +8,13 @@ import { | |||
|     LearningPathResponse, | ||||
| } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { getLogger } from '../logging/initalize.js'; | ||||
| import { v4 } from 'uuid'; | ||||
| 
 | ||||
| function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { | ||||
|     return { | ||||
|         key: data.hruid, // Hruid learningObject (not path)
 | ||||
|         _id: data._id, | ||||
|         uuid: data.uuid, | ||||
|         uuid: data.uuid || v4(), | ||||
|         version: data.version, | ||||
|         title: data.title, | ||||
|         htmlUrl, // Url to fetch html content
 | ||||
|  |  | |||
|  | @ -32,7 +32,7 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL | |||
|         educationalGoals: learningObject.educationalGoals, | ||||
|         returnValue: { | ||||
|             callback_url: learningObject.returnValue.callbackUrl, | ||||
|             callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), | ||||
|             callback_schema: learningObject.returnValue.callbackSchema === '' ? '' : JSON.parse(learningObject.returnValue.callbackSchema), | ||||
|         }, | ||||
|         skosConcepts: learningObject.skosConcepts, | ||||
|         targetAges: learningObject.targetAges || [], | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ import { | |||
|     LearningPathIdentifier, | ||||
|     LearningPathResponse, | ||||
| } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { v4 } from 'uuid'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
|  | @ -23,7 +24,7 @@ function filterData(data: LearningObjectMetadata): FilteredLearningObject { | |||
|     return { | ||||
|         key: data.hruid, // Hruid learningObject (not path)
 | ||||
|         _id: data._id, | ||||
|         uuid: data.uuid, | ||||
|         uuid: data.uuid ?? v4(), | ||||
|         version: data.version, | ||||
|         title: data.title, | ||||
|         htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content
 | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ class GiftProcessor extends StringProcessor { | |||
|         let html = "<div class='learning-object-gift'>\n"; | ||||
|         let i = 1; | ||||
|         for (const question of quizQuestions) { | ||||
|             html += `    <div class='gift-question' id='gift-q${i}'>\n`; | ||||
|             html += `    <div class='gift-question gift-question-type-${question.type}' id='gift-q${i}'>\n`; | ||||
|             html += '        ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n        $1'); // Replace for indentation.
 | ||||
|             html += `    </div>\n`; | ||||
|             i++; | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<Multipl | |||
|         for (const choice of question.choices) { | ||||
|             renderedHtml += `<div class="gift-choice-div">\n`; | ||||
|             renderedHtml += `    <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`; | ||||
|             renderedHtml += `    <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`; | ||||
|             renderedHtml += `    <label for='gift-q${questionNumber}-choice-${i}'>${choice.text.text}</label>\n`; | ||||
|             renderedHtml += `</div>\n`; | ||||
|             i++; | ||||
|         } | ||||
|  |  | |||
|  | @ -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 { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js'; | ||||
| import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; | ||||
| import { | ||||
|     FilteredLearningObject, | ||||
|     LearningObjectNode, | ||||
|  | @ -13,13 +13,16 @@ import { | |||
|     Transition, | ||||
| } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| 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'; | ||||
| 
 | ||||
| /** | ||||
|  * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its | ||||
|  * corresponding learning object. | ||||
|  * @param nodes The nodes to find the learning object for. | ||||
|  */ | ||||
| async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> { | ||||
| async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>): Promise<Map<LearningPathNode, FilteredLearningObject>> { | ||||
|     // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
 | ||||
|     // Its corresponding learning object.
 | ||||
|     const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>( | ||||
|  | @ -44,7 +47,7 @@ async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Ma | |||
| /** | ||||
|  * Convert the given learning path entity to an object which conforms to the learning path content. | ||||
|  */ | ||||
| async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> { | ||||
| async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: Group): Promise<LearningPath> { | ||||
|     // Fetch the corresponding learning object for each node since some parts of the expected response contains parts
 | ||||
|     // With information which is not available in the LearningPathNodes themselves.
 | ||||
|     const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes); | ||||
|  | @ -89,10 +92,10 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | |||
| async function convertNode( | ||||
|     node: LearningPathNode, | ||||
|     learningObject: FilteredLearningObject, | ||||
|     personalizedFor: PersonalizationTarget | undefined, | ||||
|     personalizedFor: Group | undefined, | ||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> | ||||
| ): Promise<LearningObjectNode> { | ||||
|     const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; | ||||
|     const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; | ||||
|     const transitions = node.transitions | ||||
|         .filter( | ||||
|             (trans) => | ||||
|  | @ -108,6 +111,7 @@ async function convertNode( | |||
|         updatedAt: node.updatedAt.toISOString(), | ||||
|         learningobject_hruid: node.learningObjectHruid, | ||||
|         version: learningObject.version, | ||||
|         done: personalizedFor ? lastSubmission !== null : undefined, | ||||
|         transitions, | ||||
|     }; | ||||
| } | ||||
|  | @ -121,7 +125,7 @@ async function convertNode( | |||
|  */ | ||||
| async function convertNodes( | ||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||
|     personalizedFor?: PersonalizationTarget | ||||
|     personalizedFor?: Group | ||||
| ): Promise<LearningObjectNode[]> { | ||||
|     const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) => | ||||
|         convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects) | ||||
|  | @ -161,7 +165,7 @@ function convertTransition( | |||
|             _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
 | ||||
|             default: false, // We don't work with default transitions but retain this for backwards compatibility.
 | ||||
|             next: { | ||||
|                 _id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
 | ||||
|                 _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
 | ||||
|                 hruid: transition.next.learningObjectHruid, | ||||
|                 language: nextNode.language, | ||||
|                 version: nextNode.version, | ||||
|  | @ -177,12 +181,7 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|     /** | ||||
|      * Fetch the learning paths with the given hruids from the database. | ||||
|      */ | ||||
|     async fetchLearningPaths( | ||||
|         hruids: string[], | ||||
|         language: Language, | ||||
|         source: string, | ||||
|         personalizedFor?: PersonalizationTarget | ||||
|     ): Promise<LearningPathResponse> { | ||||
|     async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse> { | ||||
|         const learningPathRepo = getLearningPathRepository(); | ||||
| 
 | ||||
|         const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( | ||||
|  | @ -202,7 +201,7 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|     /** | ||||
|      * Search learning paths in the database using the given search string. | ||||
|      */ | ||||
|     async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> { | ||||
|     async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> { | ||||
|         const learningPathRepo = getLearningPathRepository(); | ||||
| 
 | ||||
|         const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); | ||||
|  |  | |||
|  | @ -1,76 +1,22 @@ | |||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| import { Group } from '../../entities/assignments/group.entity.js'; | ||||
| import { Submission } from '../../entities/assignments/submission.entity.js'; | ||||
| import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js'; | ||||
| import { getSubmissionRepository } from '../../data/repositories.js'; | ||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||
| import { JSONPath } from 'jsonpath-plus'; | ||||
| 
 | ||||
| export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group }; | ||||
| 
 | ||||
| /** | ||||
|  * Shortcut function to easily create a PersonalizationTarget object for a student by his/her username. | ||||
|  * @param username Username of the student we want to generate a personalized learning path for. | ||||
|  *                 If there is no student with this username, return undefined. | ||||
|  * Returns the last submission for the learning object associated with the given node and for the group | ||||
|  */ | ||||
| export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> { | ||||
|     const student = await getStudentRepository().findByUsername(username); | ||||
|     if (student) { | ||||
|         return { | ||||
|             type: 'student', | ||||
|             student: student, | ||||
|         }; | ||||
|     } | ||||
|     return undefined; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and | ||||
|  * group number. | ||||
|  * @param classId Id of the class in which this group was created | ||||
|  * @param assignmentNumber Number of the assignment for which this group was created | ||||
|  * @param groupNumber Number of the group for which we want to personalize the learning path. | ||||
|  */ | ||||
| export async function personalizedForGroup( | ||||
|     classId: string, | ||||
|     assignmentNumber: number, | ||||
|     groupNumber: number | ||||
| ): Promise<PersonalizationTarget | undefined> { | ||||
|     const clazz = await getClassRepository().findById(classId); | ||||
|     if (!clazz) { | ||||
|         return undefined; | ||||
|     } | ||||
|     const group = await getGroupRepository().findOne({ | ||||
|         assignment: { | ||||
|             within: clazz, | ||||
|             id: assignmentNumber, | ||||
|         }, | ||||
|         groupNumber: groupNumber, | ||||
|     }); | ||||
|     if (group) { | ||||
|         return { | ||||
|             type: 'group', | ||||
|             group: group, | ||||
|         }; | ||||
|     } | ||||
|     return undefined; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Returns the last submission for the learning object associated with the given node and for the student or group | ||||
|  */ | ||||
| export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> { | ||||
| export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> { | ||||
|     const submissionRepo = getSubmissionRepository(); | ||||
|     const learningObjectId: LearningObjectIdentifier = { | ||||
|         hruid: node.learningObjectHruid, | ||||
|         language: node.language, | ||||
|         version: node.version, | ||||
|     }; | ||||
|     if (pathFor.type === 'group') { | ||||
|         return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group); | ||||
|     } | ||||
|     return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student); | ||||
|     return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { PersonalizationTarget } from './learning-path-personalization-util.js'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { Group } from '../../entities/assignments/group.entity'; | ||||
| 
 | ||||
| /** | ||||
|  * Generic interface for a service which provides access to learning paths from a data source. | ||||
|  | @ -9,10 +9,10 @@ export interface LearningPathProvider { | |||
|     /** | ||||
|      * Fetch the learning paths with the given hruids from the data source. | ||||
|      */ | ||||
|     fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>; | ||||
|     fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse>; | ||||
| 
 | ||||
|     /** | ||||
|      * Search learning paths in the data source using the given search string. | ||||
|      */ | ||||
|     searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>; | ||||
|     searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; | ||||
| } | ||||
|  |  | |||
|  | @ -1,13 +1,78 @@ | |||
| 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 { PersonalizationTarget } from './learning-path-personalization-util.js'; | ||||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { LearningObjectNode, LearningPath, 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'; | ||||
| import { getLearningPathRepository } from '../../data/repositories.js'; | ||||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||
| 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'; | ||||
| 
 | ||||
| const userContentPrefix = getEnvVar(envVars.UserContentPrefix); | ||||
| const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | ||||
| 
 | ||||
| export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): LearningPathEntity { | ||||
|     const admins = adminsDto.map((admin) => mapToTeacher(admin)); | ||||
|     const repo = getLearningPathRepository(); | ||||
|     const path = repo.create({ | ||||
|         hruid: dto.hruid, | ||||
|         language: dto.language as Language, | ||||
|         description: dto.description, | ||||
|         title: dto.title, | ||||
|         admins, | ||||
|         image: dto.image ? Buffer.from(base64ToArrayBuffer(dto.image)) : null, | ||||
|     }); | ||||
|     const nodes = dto.nodes.map((nodeDto: LearningObjectNode, i: number) => | ||||
|         repo.createNode({ | ||||
|             learningPath: path, | ||||
|             learningObjectHruid: nodeDto.learningobject_hruid, | ||||
|             nodeNumber: i, | ||||
|             language: nodeDto.language, | ||||
|             version: nodeDto.version, | ||||
|             startNode: nodeDto.start_node ?? false, | ||||
|             createdAt: new Date(), | ||||
|             updatedAt: new Date(), | ||||
|         }) | ||||
|     ); | ||||
|     dto.nodes.forEach((nodeDto) => { | ||||
|         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 | ||||
|                 ); | ||||
| 
 | ||||
|                 if (toNode) { | ||||
|                     return repo.createTransition({ | ||||
|                         transitionNumber: i, | ||||
|                         node: fromNode, | ||||
|                         next: toNode, | ||||
|                         condition: transDto.condition ?? 'true', | ||||
|                     }); | ||||
|                 } | ||||
|                 return undefined; | ||||
|             }) | ||||
|             .filter((it) => it) | ||||
|             .map((it) => it!); | ||||
| 
 | ||||
|         fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions); | ||||
|     }); | ||||
| 
 | ||||
|     path.nodes = new Collection<LearningPathNode>(path, nodes); | ||||
| 
 | ||||
|     return path; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) | ||||
|  */ | ||||
|  | @ -19,12 +84,7 @@ const learningPathService = { | |||
|      * @param source | ||||
|      * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. | ||||
|      */ | ||||
|     async fetchLearningPaths( | ||||
|         hruids: string[], | ||||
|         language: Language, | ||||
|         source: string, | ||||
|         personalizedFor?: PersonalizationTarget | ||||
|     ): Promise<LearningPathResponse> { | ||||
|     async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse> { | ||||
|         const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); | ||||
|         const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); | ||||
| 
 | ||||
|  | @ -48,12 +108,23 @@ const learningPathService = { | |||
|     /** | ||||
|      * Search learning paths in the data source using the given search string. | ||||
|      */ | ||||
|     async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> { | ||||
|     async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> { | ||||
|         const providerResponses = await Promise.all( | ||||
|             allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor)) | ||||
|         ); | ||||
|         return providerResponses.flat(); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * 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. | ||||
|      */ | ||||
|     async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> { | ||||
|         const repo = getLearningPathRepository(); | ||||
|         const path = mapToLearningPath(dto, admins); | ||||
|         await repo.save(path, { preventOverwrite: true }); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default learningPathService; | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import { | |||
|     getSubmissionRepository, | ||||
| } from '../data/repositories.js'; | ||||
| import { mapToClassDTO } from '../interfaces/class.js'; | ||||
| import { mapToGroupDTO, mapToShallowGroupDTO } from '../interfaces/group.js'; | ||||
| import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; | ||||
| import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; | ||||
| import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; | ||||
| import { getAllAssignments } from './assignments.js'; | ||||
|  | @ -18,8 +18,8 @@ import { NotFoundException } from '../exceptions/not-found-exception.js'; | |||
| import { fetchClass } from './classes.js'; | ||||
| import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | ||||
| import { ClassDTO } from '@dwengo-1/common/interfaces/class'; | ||||
| import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { GroupDTO } from '@dwengo-1/common/interfaces/group'; | ||||
| import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; | ||||
| import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; | ||||
| import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; | ||||
| import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | ||||
|  | @ -48,6 +48,11 @@ export async function fetchStudent(username: string): Promise<Student> { | |||
|     return user; | ||||
| } | ||||
| 
 | ||||
| export async function fetchStudents(usernames: string[]): Promise<Student[]> { | ||||
|     const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username))); | ||||
|     return members; | ||||
| } | ||||
| 
 | ||||
| export async function getStudent(username: string): Promise<StudentDTO> { | ||||
|     const user = await fetchStudent(username); | ||||
|     return mapToStudentDTO(user); | ||||
|  | @ -58,6 +63,16 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> { | |||
| 
 | ||||
|     const newStudent = mapToStudent(userData); | ||||
|     await studentRepository.save(newStudent, { preventOverwrite: true }); | ||||
| 
 | ||||
|     return userData; | ||||
| } | ||||
| 
 | ||||
| export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { | ||||
|     await getStudentRepository().upsert({ | ||||
|         username: userData.username, | ||||
|         firstName: userData.firstName, | ||||
|         lastName: userData.lastName, | ||||
|     }); | ||||
|     return userData; | ||||
| } | ||||
| 
 | ||||
|  | @ -83,7 +98,7 @@ export async function getStudentClasses(username: string, full: boolean): Promis | |||
|     return classes.map((cls) => cls.classId!); | ||||
| } | ||||
| 
 | ||||
| export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[]> { | ||||
| export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | ||||
|     const student = await fetchStudent(username); | ||||
| 
 | ||||
|     const classRepository = getClassRepository(); | ||||
|  | @ -92,17 +107,17 @@ export async function getStudentAssignments(username: string, full: boolean): Pr | |||
|     return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); | ||||
| } | ||||
| 
 | ||||
| export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[]> { | ||||
| export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> { | ||||
|     const student = await fetchStudent(username); | ||||
| 
 | ||||
|     const groupRepository = getGroupRepository(); | ||||
|     const groups = await groupRepository.findAllGroupsWithStudent(student); | ||||
| 
 | ||||
|     if (full) { | ||||
|         return groups.map(mapToGroupDTO); | ||||
|         return groups.map((group) => mapToGroupDTO(group, group.assignment.within)); | ||||
|     } | ||||
| 
 | ||||
|     return groups.map(mapToShallowGroupDTO); | ||||
|     return groups.map((group) => mapToGroupDTOId(group, group.assignment.within)); | ||||
| } | ||||
| 
 | ||||
| export async function getStudentSubmissions(username: string, full: boolean): Promise<SubmissionDTO[] | SubmissionDTOId[]> { | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { getAssignmentRepository, getSubmissionRepository } from '../data/repositories.js'; | ||||
| import { getAssignmentRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; | ||||
| import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; | ||||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||
| import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js'; | ||||
|  | @ -33,10 +33,11 @@ export async function getAllSubmissions(loId: LearningObjectIdentifier): Promise | |||
| 
 | ||||
| export async function createSubmission(submissionDTO: SubmissionDTO): Promise<SubmissionDTO> { | ||||
|     const submitter = await fetchStudent(submissionDTO.submitter.username); | ||||
|     const group = await getExistingGroupFromGroupDTO(submissionDTO.group); | ||||
|     const group = await getExistingGroupFromGroupDTO(submissionDTO.group!); | ||||
| 
 | ||||
|     const submissionRepository = getSubmissionRepository(); | ||||
|     const submission = mapToSubmission(submissionDTO, submitter, group); | ||||
| 
 | ||||
|     await submissionRepository.save(submission); | ||||
| 
 | ||||
|     return mapToSubmissionDTO(submission); | ||||
|  | @ -60,12 +61,18 @@ export async function getSubmissionsForLearningObjectAndAssignment( | |||
|     version: number, | ||||
|     classId: string, | ||||
|     assignmentId: number, | ||||
|     studentUsername?: string | ||||
|     groupId?: number | ||||
| ): Promise<SubmissionDTO[]> { | ||||
|     const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); | ||||
|     const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); | ||||
| 
 | ||||
|     const submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, studentUsername); | ||||
|     let submissions: Submission[]; | ||||
|     if (groupId !== undefined) { | ||||
|         const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, groupId); | ||||
|         submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndGroup(loId, group!); | ||||
|     } else { | ||||
|         submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!); | ||||
|     } | ||||
| 
 | ||||
|     return submissions.map((s) => mapToSubmissionDTO(s)); | ||||
| } | ||||
|  |  | |||
|  | @ -62,9 +62,19 @@ export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { | |||
| 
 | ||||
|     const newTeacher = mapToTeacher(userData); | ||||
|     await teacherRepository.save(newTeacher, { preventOverwrite: true }); | ||||
| 
 | ||||
|     return mapToTeacherDTO(newTeacher); | ||||
| } | ||||
| 
 | ||||
| export async function createOrUpdateTeacher(userData: TeacherDTO): Promise<TeacherDTO> { | ||||
|     await getTeacherRepository().upsert({ | ||||
|         username: userData.username, | ||||
|         firstName: userData.firstName, | ||||
|         lastName: userData.lastName, | ||||
|     }); | ||||
|     return userData; | ||||
| } | ||||
| 
 | ||||
| export async function deleteTeacher(username: string): Promise<TeacherDTO> { | ||||
|     const teacherRepository: TeacherRepository = getTeacherRepository(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ export class SqliteAutoincrementSubscriber implements EventSubscriber { | |||
| 
 | ||||
|         for (const prop of Object.values(args.meta.properties)) { | ||||
|             const property = prop as EntityProperty<T>; | ||||
|             if (property.primary && property.autoincrement && !(args.entity as Record<string, unknown>)[property.name]) { | ||||
|             if (property.primary && property.autoincrement && (args.entity as Record<string, unknown>)[property.name] === undefined) { | ||||
|                 // Obtain and increment sequence number of this entity.
 | ||||
|                 const propertyKey = args.meta.class.name + '.' + property.name; | ||||
|                 const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; | ||||
|  |  | |||
							
								
								
									
										12
									
								
								backend/src/util/base64-buffer-conversion.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								backend/src/util/base64-buffer-conversion.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| /** | ||||
|  * Convert a Base64-encoded string into a buffer with the same data. | ||||
|  * @param base64 The Base64 encoded string. | ||||
|  */ | ||||
| export function base64ToArrayBuffer(base64: string): ArrayBuffer { | ||||
|     const binaryString = atob(base64); | ||||
|     const bytes = new Uint8Array(binaryString.length); | ||||
|     for (let i = 0; i < binaryString.length; i++) { | ||||
|         bytes[i] = binaryString.charCodeAt(i); | ||||
|     } | ||||
|     return bytes.buffer; | ||||
| } | ||||
		Reference in a new issue