merge: fixed merge conflicts with dev

This commit is contained in:
Adriaan Jacquet 2025-04-22 17:49:11 +02:00
commit faa2f58145
165 changed files with 3948 additions and 3282 deletions

View file

@ -1,9 +1,11 @@
import { UnauthorizedException } from '../exceptions/unauthorized-exception.js';
import { getLogger } from '../logging/initalize.js';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js';
import { envVars, getEnvVar } from '../util/envVars.js';
import {AuthenticatedRequest} from "../middleware/auth/authenticated-request";
import {createStudent} from "../services/students";
import {createOrUpdateStudent, createStudent} from "../services/students";
import {AuthenticationInfo} from "../middleware/auth/authentication-info";
import {Request, Response} from "express";
import {createTeacher} from "../services/teachers";
import {createOrUpdateTeacher, createTeacher} from "../services/teachers";
interface FrontendIdpConfig {
authority: string;
@ -20,7 +22,9 @@ interface FrontendAuthConfig {
const SCOPE = 'openid profile email';
const RESPONSE_TYPE = 'code';
function getFrontendAuthConfig(): FrontendAuthConfig {
const logger = getLogger();
export function getFrontendAuthConfig(): FrontendAuthConfig {
return {
student: {
authority: getEnvVar(envVars.IdpStudentUrl),
@ -59,3 +63,24 @@ export async function handleHello(req: AuthenticatedRequest): Promise<void> {
}, true);
}
}
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!' });
}

View file

@ -3,8 +3,6 @@ import { createGroup, deleteGroup, getAllGroups, getGroup, getGroupSubmissions,
import { GroupDTO } from '@dwengo-1/common/interfaces/group';
import { requireFields } from './error-helper.js';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { EntityDTO } from '@mikro-orm/core';
import { Group } from '../entities/assignments/group.entity.js';
function checkGroupFields(classId: string, assignmentId: number, groupId: number): void {
requireFields({ classId, assignmentId, groupId });
@ -35,7 +33,11 @@ export async function putGroupHandler(req: Request, res: Response): Promise<void
const groupId = parseInt(req.params.groupid);
checkGroupFields(classId, assignmentId, groupId);
const group = await putGroup(classId, assignmentId, groupId, req.body as Partial<EntityDTO<Group>>);
// Only members field can be changed
const members = req.body.members;
requireFields({ members });
const group = await putGroup(classId, assignmentId, groupId, { members } as Partial<GroupDTO>);
res.json({ group });
}
@ -69,8 +71,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');

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import { requireFields } from './error-helper';
import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations';
import { requireFields } from './error-helper.js';
import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js';
import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation';
import {ConflictException} from "../exceptions/conflict-exception";
@ -45,7 +45,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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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';
}

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,10 @@
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';
import { Student } from '../entities/users/student.entity';
import { Group } from '../entities/assignments/group.entity';
import { getSubmissionRepository } from '../data/repositories.js';
import { Student } from '../entities/users/student.entity.js';
import { Group } from '../entities/assignments/group.entity.js';
export function mapToSubmissionDTO(submission: Submission): SubmissionDTO {
return {
@ -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,
};
}

View file

@ -1,9 +1,9 @@
import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js';
import { mapToUserDTO } from './user.js';
import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation';
import { getTeacherInvitationRepository } from '../data/repositories';
import { Teacher } from '../entities/users/teacher.entity';
import { Class } from '../entities/classes/class.entity';
import { getTeacherInvitationRepository } from '../data/repositories.js';
import { Teacher } from '../entities/users/teacher.entity.js';
import { Class } from '../entities/classes/class.entity.js';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO {

View file

@ -26,13 +26,15 @@ function initializeLogger(): Logger {
const consoleTransport = new transports.Console({
level: getEnvVar(envVars.LogLevel),
format: format.combine(format.cli(), format.colorize()),
format: format.combine(format.cli(), format.simple()),
});
if (getEnvVar(envVars.RunMode) === 'dev') {
return createLogger({
logger = createLogger({
transports: [consoleTransport],
});
logger.debug(`Logger initialized with level ${logLevel} to console`);
return logger;
}
const lokiHost = getEnvVar(envVars.LokiHost);

View file

@ -11,7 +11,7 @@ export class MikroOrmLogger extends DefaultLogger {
};
let message: string;
if (context?.label) {
if (context !== undefined && context.labels !== undefined) {
message = `[${namespace}] (${context.label}) ${messageArg}`;
} else {
message = `[${namespace}] ${messageArg}`;

View file

@ -6,6 +6,7 @@ import jwksClient from 'jwks-rsa';
import * as express from 'express';
import {AuthenticatedRequest} from './authenticated-request.js';
import {AuthenticationInfo} from './authentication-info.js';
import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js';
const JWKS_CACHE = true;
const JWKS_RATE_LIMIT = true;
@ -46,14 +47,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);

View file

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

View file

@ -1,6 +1,6 @@
import express from 'express';
import {handleGetFrontendAuthConfig, handleHello} from '../controllers/auth.js';
import {authenticatedOnly, studentsOnly, teachersOnly} from "../middleware/auth/checks/auth-checks";
import { handleGetFrontendAuthConfig, handleHello, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router();
@ -26,4 +26,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;

View file

@ -1,13 +1,12 @@
import express from 'express';
import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js';
import { onlyAllowAuthor } from '../middleware/auth/checks/question-checks.js';
import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js';
import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router({ mergeParams: true });
router.get('/', adminOnly, getSubmissionsHandler);
router.post('/:id', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);
router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);
router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler);

View file

@ -37,6 +37,6 @@ router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStude
router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler);
// Invitations to other classes a teacher received
router.get('/invitations', invitationRouter);
router.use('/invitations', invitationRouter);
export default router;

View file

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

View file

@ -1,13 +1,23 @@
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';
import { fetchClass } from './classes.js';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { Student } from '../entities/users/student.entity.js';
import { Class } from '../entities/classes/class.entity.js';
async function assertMembersInClass(members: Student[], cls: Class): Promise<void> {
if (!members.every((student) => cls.students.contains(student))) {
throw new BadRequestException('Student does not belong to class');
}
}
export async function fetchGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<Group> {
const assignment = await fetchAssignment(classId, assignmentNumber);
@ -33,20 +43,23 @@ export async function fetchAllGroups(classId: string, assignmentNumber: number):
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(
classId: string,
assignmentNumber: number,
groupNumber: number,
groupData: Partial<EntityDTO<Group>>
): Promise<GroupDTO> {
export async function putGroup(classId: string, assignmentNumber: number, groupNumber: number, groupData: Partial<GroupDTO>): Promise<GroupDTO> {
const group = await fetchGroup(classId, assignmentNumber, groupNumber);
await putObject<Group>(group, groupData, getGroupRepository());
const memberUsernames = groupData.members as string[];
const members = await fetchStudents(memberUsernames);
return mapToGroupDTO(group);
const cls = await fetchClass(classId);
await assertMembersInClass(members, cls);
const groupRepository = getGroupRepository();
groupRepository.assign(group, { members } as Partial<EntityDTO<Group>>);
await groupRepository.getEntityManager().persistAndFlush(group);
return mapToGroupDTO(group, group.assignment.within);
}
export async function deleteGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> {
@ -56,7 +69,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> {
@ -68,12 +81,11 @@ 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 cls = await fetchClass(classid);
await assertMembersInClass(members, cls);
const assignment = await fetchAssignment(classid, assignmentNumber);
@ -82,22 +94,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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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[]>;
}

View file

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

View file

@ -10,7 +10,7 @@ import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer';
import { mapToAssignment } from '../interfaces/assignment.js';
import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment';
import { fetchStudent } from './students.js';
import { NotFoundException } from '../exceptions/not-found-exception';
import { NotFoundException } from '../exceptions/not-found-exception.js';
import { FALLBACK_VERSION_NUM } from '../config.js';
import {ConflictException} from "../exceptions/conflict-exception";

View file

@ -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';
@ -49,6 +49,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,7 +63,17 @@ export async function createStudent(userData: StudentDTO, allowUpdate = false):
const studentRepository = getStudentRepository();
const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: !allowUpdate });
await studentRepository.save(newStudent, { preventOverwrite: true });
return mapToStudentDTO(newStudent);
}
export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> {
await getStudentRepository().upsert({
username: userData.username,
firstName: userData.firstName,
lastName: userData.lastName,
});
return userData;
}
@ -84,7 +99,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();
@ -93,17 +108,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[]> {

View file

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

View file

@ -1,11 +1,11 @@
import { fetchTeacher } from './teachers';
import { getTeacherInvitationRepository } from '../data/repositories';
import { mapToInvitation, mapToTeacherInvitationDTO } from '../interfaces/teacher-invitation';
import { addClassTeacher, fetchClass } from './classes';
import { fetchTeacher } from './teachers.js';
import { getTeacherInvitationRepository } from '../data/repositories.js';
import { mapToInvitation, mapToTeacherInvitationDTO } from '../interfaces/teacher-invitation.js';
import { addClassTeacher, fetchClass } from './classes.js';
import { TeacherInvitationData, TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation';
import { ConflictException } from '../exceptions/conflict-exception';
import { NotFoundException } from '../exceptions/not-found-exception';
import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity';
import { ConflictException } from '../exceptions/conflict-exception.js';
import { NotFoundException } from '../exceptions/not-found-exception.js';
import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
export async function getAllInvitations(username: string, sent: boolean): Promise<TeacherInvitationDTO[]> {

View file

@ -62,10 +62,20 @@ export async function createTeacher(userData: TeacherDTO, update?: boolean): Pro
const teacherRepository: TeacherRepository = getTeacherRepository();
const newTeacher = mapToTeacher(userData);
await teacherRepository.save(newTeacher, { preventOverwrite: !update });
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();

View file

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

View 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;
}