Merge branch 'dev' into feat/discussions
This commit is contained in:
		
						commit
						e28a57754f
					
				
					 44 changed files with 2270 additions and 767 deletions
				
			
		|  | @ -11,8 +11,7 @@ import { | |||
| import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { requireFields } from './error-helper.js'; | ||||
| import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||
| import { Assignment } from '../entities/assignments/assignment.entity.js'; | ||||
| import { EntityDTO } from '@mikro-orm/core'; | ||||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| 
 | ||||
| function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } { | ||||
|     const classid = req.params.classid; | ||||
|  | @ -38,14 +37,19 @@ export async function getAllAssignmentsHandler(req: Request, res: Response): Pro | |||
| 
 | ||||
| export async function createAssignmentHandler(req: Request, res: Response): Promise<void> { | ||||
|     const classid = req.params.classid; | ||||
|     const description = req.body.description; | ||||
|     const language = req.body.language; | ||||
|     const learningPath = req.body.learningPath; | ||||
|     const description = req.body.description || ''; | ||||
|     const language = req.body.language || FALLBACK_LANG; | ||||
|     const learningPath = req.body.learningPath || ''; | ||||
|     const title = req.body.title; | ||||
| 
 | ||||
|     requireFields({ description, language, learningPath, title }); | ||||
|     requireFields({ title }); | ||||
| 
 | ||||
|     const assignmentData = req.body as AssignmentDTO; | ||||
|     const assignmentData = { | ||||
|         description: description, | ||||
|         language: language, | ||||
|         learningPath: learningPath, | ||||
|         title: title, | ||||
|     } as AssignmentDTO; | ||||
|     const assignment = await createAssignment(classid, assignmentData); | ||||
| 
 | ||||
|     res.json({ assignment }); | ||||
|  | @ -62,7 +66,7 @@ export async function getAssignmentHandler(req: Request, res: Response): Promise | |||
| export async function putAssignmentHandler(req: Request, res: Response): Promise<void> { | ||||
|     const { classid, assignmentNumber } = getAssignmentParams(req); | ||||
| 
 | ||||
|     const assignmentData = req.body as Partial<EntityDTO<Assignment>>; | ||||
|     const assignmentData = req.body as Partial<AssignmentDTO>; | ||||
|     const assignment = await putAssignment(classid, assignmentNumber, assignmentData); | ||||
| 
 | ||||
|     res.json({ assignment }); | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { | |||
|     getJoinRequestsByClass, | ||||
|     getStudentsByTeacher, | ||||
|     getTeacher, | ||||
|     getTeacherAssignments, | ||||
|     updateClassJoinRequestStatus, | ||||
| } from '../services/teachers.js'; | ||||
| import { requireFields } from './error-helper.js'; | ||||
|  | @ -59,6 +60,16 @@ export async function getTeacherClassHandler(req: Request, res: Response): Promi | |||
|     res.json({ classes }); | ||||
| } | ||||
| 
 | ||||
| export async function getTeacherAssignmentsHandler(req: Request, res: Response): Promise<void> { | ||||
|     const username = req.params.username; | ||||
|     const full = req.query.full === 'true'; | ||||
|     requireFields({ username }); | ||||
| 
 | ||||
|     const assignments = await getTeacherAssignments(username, full); | ||||
| 
 | ||||
|     res.json({ assignments }); | ||||
| } | ||||
| 
 | ||||
| export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> { | ||||
|     const username = req.params.username; | ||||
|     const full = req.query.full === 'true'; | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | |||
|         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 }); | ||||
|         return this.findOne({ within: { classId: withinClass }, id: id }, { populate: ['groups', 'groups.members'] }); | ||||
|     } | ||||
|     public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> { | ||||
|         return this.findAll({ | ||||
|  | @ -20,6 +20,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | |||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             populate: ['groups', 'groups.members'], | ||||
|         }); | ||||
|     } | ||||
|     public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { | ||||
|  |  | |||
|  | @ -28,4 +28,9 @@ export class GroupRepository extends DwengoEntityRepository<Group> { | |||
|             groupNumber: groupNumber, | ||||
|         }); | ||||
|     } | ||||
|     public async deleteAllByAssignment(assignment: Assignment): Promise<void> { | ||||
|         return this.deleteAllWhere({ | ||||
|             assignment: assignment, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -16,4 +16,13 @@ export abstract class DwengoEntityRepository<T extends object> extends EntityRep | |||
|             await em.flush(); | ||||
|         } | ||||
|     } | ||||
|     public async deleteAllWhere(query: FilterQuery<T>): Promise<void> { | ||||
|         const toDelete = await this.find(query); | ||||
|         const em = this.getEntityManager(); | ||||
| 
 | ||||
|         if (toDelete) { | ||||
|             em.remove(toDelete); | ||||
|             await em.flush(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -20,7 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { | |||
|         description: assignment.description, | ||||
|         learningPath: assignment.learningPathHruid, | ||||
|         language: assignment.learningPathLanguage, | ||||
|         deadline: assignment.deadline ?? new Date(), | ||||
|         deadline: assignment.deadline ?? null, | ||||
|         groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), | ||||
|     }; | ||||
| } | ||||
|  |  | |||
|  | @ -7,10 +7,16 @@ 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'; | ||||
| import { fetchClass } from '../../../services/classes.js'; | ||||
| import { fetchGroup } from '../../../services/groups.js'; | ||||
| import { requireFields } from '../../../controllers/error-helper.js'; | ||||
| import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; | ||||
| 
 | ||||
| export const onlyAllowSubmitter = authorize( | ||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username | ||||
| ); | ||||
| export const onlyAllowSubmitter = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const submittedFor = (req.body as SubmissionDTO).submitter.username; | ||||
|     const submittedBy = auth.username; | ||||
|     return submittedFor === submittedBy; | ||||
| }); | ||||
| 
 | ||||
| export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const { hruid: lohruid, id: submissionNumber } = req.params; | ||||
|  | @ -26,3 +32,17 @@ export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: Authentic | |||
| 
 | ||||
|     return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
| 
 | ||||
| export const onlyAllowIfHasAccessToSubmissionFromParams = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const { classId, assignmentId, groupId } = req.query; | ||||
| 
 | ||||
|     requireFields({ classId, assignmentId, groupId }); | ||||
| 
 | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         const cls = await fetchClass(classId as string); | ||||
|         return cls.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } | ||||
| 
 | ||||
|     const group = await fetchGroup(classId as string, Number(assignmentId as string), Number(groupId as string)); | ||||
|     return group.members.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,10 +1,14 @@ | |||
| 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'; | ||||
| import { | ||||
|     onlyAllowIfHasAccessToSubmission, | ||||
|     onlyAllowIfHasAccessToSubmissionFromParams, | ||||
|     onlyAllowSubmitter, | ||||
| } from '../middleware/auth/checks/submission-checks.js'; | ||||
| import { studentsOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| router.get('/', adminOnly, getSubmissionsHandler); | ||||
| router.get('/', onlyAllowIfHasAccessToSubmissionFromParams, getSubmissionsHandler); | ||||
| 
 | ||||
| router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler); | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import { | |||
|     deleteTeacherHandler, | ||||
|     getAllTeachersHandler, | ||||
|     getStudentJoinRequestHandler, | ||||
|     getTeacherAssignmentsHandler, | ||||
|     getTeacherClassHandler, | ||||
|     getTeacherHandler, | ||||
|     getTeacherStudentHandler, | ||||
|  | @ -28,6 +29,8 @@ router.get('/:username/classes', preventImpersonation, getTeacherClassHandler); | |||
| 
 | ||||
| router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); | ||||
| 
 | ||||
| router.get(`/:username/assignments`, getTeacherAssignmentsHandler); | ||||
| 
 | ||||
| router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); | ||||
| 
 | ||||
| router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); | ||||
|  |  | |||
|  | @ -14,10 +14,13 @@ import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submissi | |||
| import { fetchClass } from './classes.js'; | ||||
| 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 { EntityDTO, ForeignKeyConstraintViolationException } from '@mikro-orm/core'; | ||||
| import { putObject } from './service-helper.js'; | ||||
| import { fetchStudents } from './students.js'; | ||||
| import { ServerErrorException } from '../exceptions/server-error-exception.js'; | ||||
| import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||
| import { PostgreSqlExceptionConverter } from '@mikro-orm/postgresql'; | ||||
| 
 | ||||
| export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> { | ||||
|     const classRepository = getClassRepository(); | ||||
|  | @ -59,7 +62,7 @@ export async function createAssignment(classid: string, assignmentData: Assignme | |||
| 
 | ||||
|     if (assignmentData.groups) { | ||||
|         /* | ||||
|         For some reason when trying to add groups, it does not work when using the original assignment variable.  | ||||
|         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. | ||||
|         */ | ||||
| 
 | ||||
|  | @ -93,10 +96,36 @@ export async function getAssignment(classid: string, id: number): Promise<Assign | |||
|     return mapToAssignmentDTO(assignment); | ||||
| } | ||||
| 
 | ||||
| export async function putAssignment(classid: string, id: number, assignmentData: Partial<EntityDTO<Assignment>>): Promise<AssignmentDTO> { | ||||
| function hasDuplicates(arr: string[]): boolean { | ||||
|     return new Set(arr).size !== arr.length; | ||||
| } | ||||
| 
 | ||||
| export async function putAssignment(classid: string, id: number, assignmentData: Partial<AssignmentDTO>): Promise<AssignmentDTO> { | ||||
|     const assignment = await fetchAssignment(classid, id); | ||||
| 
 | ||||
|     await putObject<Assignment>(assignment, assignmentData, getAssignmentRepository()); | ||||
|     if (assignmentData.groups) { | ||||
|         if (hasDuplicates(assignmentData.groups.flat() as string[])) { | ||||
|             throw new BadRequestException('Student can only be in one group'); | ||||
|         } | ||||
| 
 | ||||
|         const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group))); | ||||
| 
 | ||||
|         const groupRepository = getGroupRepository(); | ||||
|         await groupRepository.deleteAllByAssignment(assignment); | ||||
|         await Promise.all( | ||||
|             studentLists.map(async (students) => { | ||||
|                 const newGroup = groupRepository.create({ | ||||
|                     assignment: assignment, | ||||
|                     members: students, | ||||
|                 }); | ||||
|                 await groupRepository.save(newGroup); | ||||
|             }) | ||||
|         ); | ||||
| 
 | ||||
|         delete assignmentData.groups; | ||||
|     } | ||||
| 
 | ||||
|     await putObject<Assignment>(assignment, assignmentData as Partial<EntityDTO<Assignment>>, getAssignmentRepository()); | ||||
| 
 | ||||
|     return mapToAssignmentDTO(assignment); | ||||
| } | ||||
|  | @ -106,7 +135,16 @@ export async function deleteAssignment(classid: string, id: number): Promise<Ass | |||
|     const cls = await fetchClass(classid); | ||||
| 
 | ||||
|     const assignmentRepository = getAssignmentRepository(); | ||||
|     await assignmentRepository.deleteByClassAndId(cls, id); | ||||
| 
 | ||||
|     try { | ||||
|         await assignmentRepository.deleteByClassAndId(cls, id); | ||||
|     } catch (e: unknown) { | ||||
|         if (e instanceof ForeignKeyConstraintViolationException || e instanceof PostgreSqlExceptionConverter) { | ||||
|             throw new ConflictException('Cannot delete assigment with questions or submissions'); | ||||
|         } else { | ||||
|             throw e; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     return mapToAssignmentDTO(assignment); | ||||
| } | ||||
|  |  | |||
|  | @ -70,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, | ||||
|  | @ -79,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), | ||||
|  | @ -180,7 +182,6 @@ function convertTransition( | |||
|         return { | ||||
|             _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.
 | ||||
|             condition: transition.condition, | ||||
|             next: { | ||||
|                 _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
 | ||||
|                 hruid: transition.next.learningObjectHruid, | ||||
|  | @ -191,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. | ||||
|  */ | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ import { mapToClassDTO } from '../interfaces/class.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'; | ||||
| import { fetchAssignment } from './assignments.js'; | ||||
| import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; | ||||
| import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js'; | ||||
| import { Student } from '../entities/users/student.entity.js'; | ||||
|  | @ -26,6 +26,7 @@ import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-requ | |||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||
| import { Submission } from '../entities/assignments/submission.entity.js'; | ||||
| import { mapToUsername } from '../interfaces/user.js'; | ||||
| import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; | ||||
| 
 | ||||
| export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { | ||||
|     const studentRepository = getStudentRepository(); | ||||
|  | @ -50,8 +51,7 @@ export async function fetchStudent(username: string): Promise<Student> { | |||
| } | ||||
| 
 | ||||
| export async function fetchStudents(usernames: string[]): Promise<Student[]> { | ||||
|     const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username))); | ||||
|     return members; | ||||
|     return await Promise.all(usernames.map(async (username) => await fetchStudent(username))); | ||||
| } | ||||
| 
 | ||||
| export async function getStudent(username: string): Promise<StudentDTO> { | ||||
|  | @ -102,10 +102,14 @@ export async function getStudentClasses(username: string, full: boolean): Promis | |||
| export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | ||||
|     const student = await fetchStudent(username); | ||||
| 
 | ||||
|     const classRepository = getClassRepository(); | ||||
|     const classes = await classRepository.findByStudent(student); | ||||
|     const groupRepository = getGroupRepository(); | ||||
|     const groups = await groupRepository.findAllGroupsWithStudent(student); | ||||
|     const assignments = await Promise.all(groups.map(async (group) => await fetchAssignment(group.assignment.within.classId!, group.assignment.id!))); | ||||
| 
 | ||||
|     return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); | ||||
|     if (full) { | ||||
|         return assignments.map(mapToAssignmentDTO); | ||||
|     } | ||||
|     return assignments.map(mapToAssignmentDTOId); | ||||
| } | ||||
| 
 | ||||
| export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> { | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js'; | ||||
| import { getAssignmentRepository, getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js'; | ||||
| import { mapToClassDTO } from '../interfaces/class.js'; | ||||
| import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; | ||||
| import { Teacher } from '../entities/users/teacher.entity.js'; | ||||
|  | @ -18,6 +18,8 @@ import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | |||
| import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | ||||
| import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | ||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||
| import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | ||||
| import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; | ||||
| import { mapToUsername } from '../interfaces/user.js'; | ||||
| 
 | ||||
| export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { | ||||
|  | @ -91,6 +93,17 @@ export async function getClassesByTeacher(username: string, full: boolean): Prom | |||
|     return classes.map((cls) => cls.id); | ||||
| } | ||||
| 
 | ||||
| export async function getTeacherAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | ||||
|     const assignmentRepository = getAssignmentRepository(); | ||||
|     const assignments = await assignmentRepository.findAllByResponsibleTeacher(username); | ||||
| 
 | ||||
|     if (full) { | ||||
|         return assignments.map(mapToAssignmentDTO); | ||||
|     } | ||||
| 
 | ||||
|     return assignments.map(mapToAssignmentDTOId); | ||||
| } | ||||
| 
 | ||||
| export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[]> { | ||||
|     const classes: ClassDTO[] = await fetchClassesByTeacher(username); | ||||
| 
 | ||||
|  |  | |||
		Reference in a new issue