Merge branch 'dev' into feat/discussions

This commit is contained in:
Tibo De Peuter 2025-05-19 19:59:10 +02:00
commit e28a57754f
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
44 changed files with 2270 additions and 767 deletions

View file

@ -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 });

View file

@ -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';

View file

@ -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[]> {

View file

@ -28,4 +28,9 @@ export class GroupRepository extends DwengoEntityRepository<Group> {
groupNumber: groupNumber,
});
}
public async deleteAllByAssignment(assignment: Assignment): Promise<void> {
return this.deleteAllWhere({
assignment: assignment,
});
}
}

View file

@ -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();
}
}
}

View file

@ -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)),
};
}

View file

@ -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);
});

View file

@ -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);

View file

@ -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);

View file

@ -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);
}

View file

@ -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.
*/

View file

@ -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[]> {

View file

@ -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);