merge: fixed merge conflicts
This commit is contained in:
commit
90f43e74ba
101 changed files with 2156 additions and 1470 deletions
|
@ -3,13 +3,10 @@ import { themes } from '../data/themes.js';
|
|||
import { FALLBACK_LANG } from '../config.js';
|
||||
import learningPathService from '../services/learning-paths/learning-path-service.js';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import {
|
||||
PersonalizationTarget,
|
||||
personalizedForGroup,
|
||||
personalizedForStudent,
|
||||
} from '../services/learning-paths/learning-path-personalization-util.js';
|
||||
import { BadRequestException } from '../exceptions/bad-request-exception.js';
|
||||
import { NotFoundException } from '../exceptions/not-found-exception.js';
|
||||
import { Group } from '../entities/assignments/group.entity.js';
|
||||
import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js';
|
||||
|
||||
/**
|
||||
* Fetch learning paths based on query parameters.
|
||||
|
@ -20,20 +17,20 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
|
|||
const searchQuery = req.query.search as string;
|
||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
||||
|
||||
const forStudent = req.query.forStudent as string;
|
||||
const forGroupNo = req.query.forGroup as string;
|
||||
const assignmentNo = req.query.assignmentNo as string;
|
||||
const classId = req.query.classId as string;
|
||||
|
||||
let personalizationTarget: PersonalizationTarget | undefined;
|
||||
let forGroup: Group | undefined;
|
||||
|
||||
if (forStudent) {
|
||||
personalizationTarget = await personalizedForStudent(forStudent);
|
||||
} else if (forGroupNo) {
|
||||
if (forGroupNo) {
|
||||
if (!assignmentNo || !classId) {
|
||||
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
|
||||
}
|
||||
personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo));
|
||||
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
|
||||
if (assignment) {
|
||||
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let hruidList;
|
||||
|
@ -48,18 +45,13 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
|
|||
throw new NotFoundException(`Theme "${themeKey}" not found.`);
|
||||
}
|
||||
} else if (searchQuery) {
|
||||
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget);
|
||||
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
|
||||
res.json(searchResults);
|
||||
return;
|
||||
} else {
|
||||
hruidList = themes.flatMap((theme) => theme.hruids);
|
||||
}
|
||||
|
||||
const learningPaths = await learningPathService.fetchLearningPaths(
|
||||
hruidList,
|
||||
language as Language,
|
||||
`HRUIDs: ${hruidList.join(', ')}`,
|
||||
personalizationTarget
|
||||
);
|
||||
const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup);
|
||||
res.json(learningPaths.data);
|
||||
}
|
||||
|
|
|
@ -17,15 +17,18 @@ export async function getSubmissionsHandler(req: Request, res: Response): Promis
|
|||
const lang = languageMap[req.query.language as string] || Language.Dutch;
|
||||
const version = parseInt(req.query.version as string) ?? 1;
|
||||
|
||||
const submissions = await getSubmissionsForLearningObjectAndAssignment(
|
||||
const forGroup = req.query.forGroup as string | undefined;
|
||||
|
||||
const submissions: SubmissionDTO[] = await getSubmissionsForLearningObjectAndAssignment(
|
||||
loHruid,
|
||||
lang,
|
||||
version,
|
||||
req.query.classId as string,
|
||||
parseInt(req.query.assignmentId as string)
|
||||
parseInt(req.query.assignmentId as string),
|
||||
forGroup ? parseInt(forGroup) : undefined
|
||||
);
|
||||
|
||||
res.json(submissions);
|
||||
res.json({ submissions });
|
||||
}
|
||||
|
||||
export async function getSubmissionHandler(req: Request, res: Response): Promise<void> {
|
||||
|
|
|
@ -61,32 +61,30 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
|
|||
|
||||
/**
|
||||
* Looks up all submissions for the given learning object which were submitted as part of the given assignment.
|
||||
* When forStudentUsername is set, only the submissions of the given user's group are shown.
|
||||
*/
|
||||
public async findAllSubmissionsForLearningObjectAndAssignment(
|
||||
loId: LearningObjectIdentifier,
|
||||
assignment: Assignment,
|
||||
forStudentUsername?: string
|
||||
): Promise<Submission[]> {
|
||||
const onBehalfOf = forStudentUsername
|
||||
? {
|
||||
assignment,
|
||||
members: {
|
||||
$some: {
|
||||
username: forStudentUsername,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
assignment,
|
||||
};
|
||||
|
||||
public async findAllSubmissionsForLearningObjectAndAssignment(loId: LearningObjectIdentifier, assignment: Assignment): Promise<Submission[]> {
|
||||
return this.findAll({
|
||||
where: {
|
||||
learningObjectHruid: loId.hruid,
|
||||
learningObjectLanguage: loId.language,
|
||||
learningObjectVersion: loId.version,
|
||||
onBehalfOf,
|
||||
onBehalfOf: {
|
||||
assignment,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up all submissions for the given learning object which were submitted by the given group
|
||||
*/
|
||||
public async findAllSubmissionsForLearningObjectAndGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission[]> {
|
||||
return this.findAll({
|
||||
where: {
|
||||
learningObjectHruid: loId.hruid,
|
||||
learningObjectLanguage: loId.language,
|
||||
learningObjectVersion: loId.version,
|
||||
onBehalfOf: group,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
|
||||
import { LearningPath } from '../../entities/content/learning-path.entity.js';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||
import { RequiredEntityData } from '@mikro-orm/core';
|
||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||
import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js';
|
||||
|
||||
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
||||
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
||||
|
@ -23,4 +27,27 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
|
|||
populate: ['nodes', 'nodes.transitions'],
|
||||
});
|
||||
}
|
||||
|
||||
public createNode(nodeData: RequiredEntityData<LearningPathNode>): LearningPathNode {
|
||||
return this.em.create(LearningPathNode, nodeData);
|
||||
}
|
||||
|
||||
public createTransition(transitionData: RequiredEntityData<LearningPathTransition>): LearningPathTransition {
|
||||
return this.em.create(LearningPathTransition, transitionData);
|
||||
}
|
||||
|
||||
public async saveLearningPathNodesAndTransitions(
|
||||
path: LearningPath,
|
||||
nodes: LearningPathNode[],
|
||||
transitions: LearningPathTransition[],
|
||||
options?: { preventOverwrite?: boolean }
|
||||
): Promise<void> {
|
||||
if (options?.preventOverwrite && (await this.findOne(path))) {
|
||||
throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.');
|
||||
}
|
||||
const em = this.getEntityManager();
|
||||
await em.persistAndFlush(path);
|
||||
await Promise.all(nodes.map(async (it) => em.persistAndFlush(it)));
|
||||
await Promise.all(transitions.map(async (it) => em.persistAndFlush(it)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Attachment } from './attachment.entity.js';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js';
|
||||
|
@ -42,7 +42,7 @@ export class LearningObject {
|
|||
@Property({ type: 'array' })
|
||||
keywords: string[] = [];
|
||||
|
||||
@Property({ type: 'array', nullable: true })
|
||||
@Property({ type: new ArrayType((i) => Number(i)), nullable: true })
|
||||
targetAges?: number[] = [];
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
|
||||
import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
|
||||
import { LearningPath } from './learning-path.entity.js';
|
||||
import { LearningPathTransition } from './learning-path-transition.entity.js';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
|
||||
@Entity()
|
||||
export class LearningPathNode {
|
||||
@PrimaryKey({ type: 'integer', autoincrement: true })
|
||||
nodeNumber?: number;
|
||||
|
||||
@ManyToOne({ entity: () => LearningPath, primary: true })
|
||||
learningPath!: Rel<LearningPath>;
|
||||
|
||||
@PrimaryKey({ type: 'integer', autoincrement: true })
|
||||
nodeNumber!: number;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
learningObjectHruid!: string;
|
||||
|
||||
|
@ -27,7 +27,7 @@ export class LearningPathNode {
|
|||
startNode!: boolean;
|
||||
|
||||
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' })
|
||||
transitions: LearningPathTransition[] = [];
|
||||
transitions!: Collection<LearningPathTransition>;
|
||||
|
||||
@Property({ length: 3 })
|
||||
createdAt: Date = new Date();
|
||||
|
|
|
@ -3,12 +3,12 @@ import { LearningPathNode } from './learning-path-node.entity.js';
|
|||
|
||||
@Entity()
|
||||
export class LearningPathTransition {
|
||||
@ManyToOne({ entity: () => LearningPathNode, primary: true })
|
||||
node!: Rel<LearningPathNode>;
|
||||
|
||||
@PrimaryKey({ type: 'numeric' })
|
||||
transitionNumber!: number;
|
||||
|
||||
@ManyToOne({ entity: () => LearningPathNode, primary: true })
|
||||
node!: Rel<LearningPathNode>;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
condition!: string;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
|
||||
import { LearningPathNode } from './learning-path-node.entity.js';
|
||||
|
@ -25,5 +25,5 @@ export class LearningPath {
|
|||
image: Buffer | null = null;
|
||||
|
||||
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
|
||||
nodes: LearningPathNode[] = [];
|
||||
nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Submission } from '../entities/assignments/submission.entity.js';
|
||||
import { mapToGroupDTO } from './group.js';
|
||||
import { mapToGroupDTOId } from './group.js';
|
||||
import { mapToStudentDTO } from './student.js';
|
||||
import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission';
|
||||
import { getSubmissionRepository } from '../data/repositories.js';
|
||||
|
@ -13,11 +13,10 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO {
|
|||
language: submission.learningObjectLanguage,
|
||||
version: submission.learningObjectVersion,
|
||||
},
|
||||
|
||||
submissionNumber: submission.submissionNumber,
|
||||
submitter: mapToStudentDTO(submission.submitter),
|
||||
time: submission.submissionTime,
|
||||
group: submission.onBehalfOf ? mapToGroupDTO(submission.onBehalfOf, submission.onBehalfOf.assignment.within) : undefined,
|
||||
group: submission.onBehalfOf ? mapToGroupDTOId(submission.onBehalfOf, submission.onBehalfOf.assignment.within) : undefined,
|
||||
content: submission.content,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _: Next
|
|||
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: ${err}`);
|
||||
logger.error(`Unexpected error occurred while handing a request: ${(err as { stack: string })?.stack ?? JSON.stringify(err)}`);
|
||||
res.status(500).json(err);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ const router = express.Router({ mergeParams: true });
|
|||
// Root endpoint used to search objects
|
||||
router.get('/', getSubmissionsHandler);
|
||||
|
||||
router.post('/:id', createSubmissionHandler);
|
||||
router.post('/', createSubmissionHandler);
|
||||
|
||||
// Information about an submission with id 'id'
|
||||
router.get('/:id', getSubmissionHandler);
|
||||
|
|
|
@ -8,12 +8,13 @@ import {
|
|||
LearningPathResponse,
|
||||
} from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { getLogger } from '../logging/initalize.js';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject {
|
||||
return {
|
||||
key: data.hruid, // Hruid learningObject (not path)
|
||||
_id: data._id,
|
||||
uuid: data.uuid,
|
||||
uuid: data.uuid || v4(),
|
||||
version: data.version,
|
||||
title: data.title,
|
||||
htmlUrl, // Url to fetch html content
|
||||
|
|
|
@ -32,7 +32,7 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL
|
|||
educationalGoals: learningObject.educationalGoals,
|
||||
returnValue: {
|
||||
callback_url: learningObject.returnValue.callbackUrl,
|
||||
callback_schema: JSON.parse(learningObject.returnValue.callbackSchema),
|
||||
callback_schema: learningObject.returnValue.callbackSchema === '' ? '' : JSON.parse(learningObject.returnValue.callbackSchema),
|
||||
},
|
||||
skosConcepts: learningObject.skosConcepts,
|
||||
targetAges: learningObject.targetAges || [],
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
LearningPathIdentifier,
|
||||
LearningPathResponse,
|
||||
} from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
|
@ -23,7 +24,7 @@ function filterData(data: LearningObjectMetadata): FilteredLearningObject {
|
|||
return {
|
||||
key: data.hruid, // Hruid learningObject (not path)
|
||||
_id: data._id,
|
||||
uuid: data.uuid,
|
||||
uuid: data.uuid ?? v4(),
|
||||
version: data.version,
|
||||
title: data.title,
|
||||
htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content
|
||||
|
|
|
@ -38,7 +38,7 @@ class GiftProcessor extends StringProcessor {
|
|||
let html = "<div class='learning-object-gift'>\n";
|
||||
let i = 1;
|
||||
for (const question of quizQuestions) {
|
||||
html += ` <div class='gift-question' id='gift-q${i}'>\n`;
|
||||
html += ` <div class='gift-question gift-question-type-${question.type}' id='gift-q${i}'>\n`;
|
||||
html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation.
|
||||
html += ` </div>\n`;
|
||||
i++;
|
||||
|
|
|
@ -14,7 +14,7 @@ export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<Multipl
|
|||
for (const choice of question.choices) {
|
||||
renderedHtml += `<div class="gift-choice-div">\n`;
|
||||
renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`;
|
||||
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`;
|
||||
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text.text}</label>\n`;
|
||||
renderedHtml += `</div>\n`;
|
||||
i++;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js';
|
|||
import learningObjectService from '../learning-objects/learning-object-service.js';
|
||||
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||
import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js';
|
||||
import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js';
|
||||
import {
|
||||
FilteredLearningObject,
|
||||
LearningObjectNode,
|
||||
|
@ -13,13 +13,16 @@ import {
|
|||
Transition,
|
||||
} from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { Group } from '../../entities/assignments/group.entity';
|
||||
import { Collection } from '@mikro-orm/core';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
|
||||
* corresponding learning object.
|
||||
* @param nodes The nodes to find the learning object for.
|
||||
*/
|
||||
async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> {
|
||||
async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>): Promise<Map<LearningPathNode, FilteredLearningObject>> {
|
||||
// Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
|
||||
// Its corresponding learning object.
|
||||
const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>(
|
||||
|
@ -44,7 +47,7 @@ async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Ma
|
|||
/**
|
||||
* Convert the given learning path entity to an object which conforms to the learning path content.
|
||||
*/
|
||||
async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> {
|
||||
async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: Group): Promise<LearningPath> {
|
||||
// Fetch the corresponding learning object for each node since some parts of the expected response contains parts
|
||||
// With information which is not available in the LearningPathNodes themselves.
|
||||
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
|
||||
|
@ -89,10 +92,10 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
|
|||
async function convertNode(
|
||||
node: LearningPathNode,
|
||||
learningObject: FilteredLearningObject,
|
||||
personalizedFor: PersonalizationTarget | undefined,
|
||||
personalizedFor: Group | undefined,
|
||||
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
|
||||
): Promise<LearningObjectNode> {
|
||||
const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null;
|
||||
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null;
|
||||
const transitions = node.transitions
|
||||
.filter(
|
||||
(trans) =>
|
||||
|
@ -108,6 +111,7 @@ async function convertNode(
|
|||
updatedAt: node.updatedAt.toISOString(),
|
||||
learningobject_hruid: node.learningObjectHruid,
|
||||
version: learningObject.version,
|
||||
done: personalizedFor ? lastSubmission !== null : undefined,
|
||||
transitions,
|
||||
};
|
||||
}
|
||||
|
@ -121,7 +125,7 @@ async function convertNode(
|
|||
*/
|
||||
async function convertNodes(
|
||||
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
personalizedFor?: Group
|
||||
): Promise<LearningObjectNode[]> {
|
||||
const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) =>
|
||||
convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects)
|
||||
|
@ -161,7 +165,7 @@ function convertTransition(
|
|||
_id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
|
||||
default: false, // We don't work with default transitions but retain this for backwards compatibility.
|
||||
next: {
|
||||
_id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
|
||||
_id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
|
||||
hruid: transition.next.learningObjectHruid,
|
||||
language: nextNode.language,
|
||||
version: nextNode.version,
|
||||
|
@ -177,12 +181,7 @@ const databaseLearningPathProvider: LearningPathProvider = {
|
|||
/**
|
||||
* Fetch the learning paths with the given hruids from the database.
|
||||
*/
|
||||
async fetchLearningPaths(
|
||||
hruids: string[],
|
||||
language: Language,
|
||||
source: string,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
): Promise<LearningPathResponse> {
|
||||
async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse> {
|
||||
const learningPathRepo = getLearningPathRepository();
|
||||
|
||||
const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter(
|
||||
|
@ -202,7 +201,7 @@ const databaseLearningPathProvider: LearningPathProvider = {
|
|||
/**
|
||||
* Search learning paths in the database using the given search string.
|
||||
*/
|
||||
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
|
||||
async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> {
|
||||
const learningPathRepo = getLearningPathRepository();
|
||||
|
||||
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);
|
||||
|
|
|
@ -1,76 +1,22 @@
|
|||
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||
import { Student } from '../../entities/users/student.entity.js';
|
||||
import { Group } from '../../entities/assignments/group.entity.js';
|
||||
import { Submission } from '../../entities/assignments/submission.entity.js';
|
||||
import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js';
|
||||
import { getSubmissionRepository } from '../../data/repositories.js';
|
||||
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
|
||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||
import { JSONPath } from 'jsonpath-plus';
|
||||
|
||||
export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group };
|
||||
|
||||
/**
|
||||
* Shortcut function to easily create a PersonalizationTarget object for a student by his/her username.
|
||||
* @param username Username of the student we want to generate a personalized learning path for.
|
||||
* If there is no student with this username, return undefined.
|
||||
* Returns the last submission for the learning object associated with the given node and for the group
|
||||
*/
|
||||
export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> {
|
||||
const student = await getStudentRepository().findByUsername(username);
|
||||
if (student) {
|
||||
return {
|
||||
type: 'student',
|
||||
student: student,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and
|
||||
* group number.
|
||||
* @param classId Id of the class in which this group was created
|
||||
* @param assignmentNumber Number of the assignment for which this group was created
|
||||
* @param groupNumber Number of the group for which we want to personalize the learning path.
|
||||
*/
|
||||
export async function personalizedForGroup(
|
||||
classId: string,
|
||||
assignmentNumber: number,
|
||||
groupNumber: number
|
||||
): Promise<PersonalizationTarget | undefined> {
|
||||
const clazz = await getClassRepository().findById(classId);
|
||||
if (!clazz) {
|
||||
return undefined;
|
||||
}
|
||||
const group = await getGroupRepository().findOne({
|
||||
assignment: {
|
||||
within: clazz,
|
||||
id: assignmentNumber,
|
||||
},
|
||||
groupNumber: groupNumber,
|
||||
});
|
||||
if (group) {
|
||||
return {
|
||||
type: 'group',
|
||||
group: group,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last submission for the learning object associated with the given node and for the student or group
|
||||
*/
|
||||
export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> {
|
||||
export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> {
|
||||
const submissionRepo = getSubmissionRepository();
|
||||
const learningObjectId: LearningObjectIdentifier = {
|
||||
hruid: node.learningObjectHruid,
|
||||
language: node.language,
|
||||
version: node.version,
|
||||
};
|
||||
if (pathFor.type === 'group') {
|
||||
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
|
||||
}
|
||||
return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
|
||||
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { PersonalizationTarget } from './learning-path-personalization-util.js';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { Group } from '../../entities/assignments/group.entity';
|
||||
|
||||
/**
|
||||
* Generic interface for a service which provides access to learning paths from a data source.
|
||||
|
@ -9,10 +9,10 @@ export interface LearningPathProvider {
|
|||
/**
|
||||
* Fetch the learning paths with the given hruids from the data source.
|
||||
*/
|
||||
fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>;
|
||||
fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse>;
|
||||
|
||||
/**
|
||||
* Search learning paths in the data source using the given search string.
|
||||
*/
|
||||
searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>;
|
||||
searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,78 @@
|
|||
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
|
||||
import databaseLearningPathProvider from './database-learning-path-provider.js';
|
||||
import { envVars, getEnvVar } from '../../util/envVars.js';
|
||||
import { PersonalizationTarget } from './learning-path-personalization-util.js';
|
||||
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { Group } from '../../entities/assignments/group.entity.js';
|
||||
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
|
||||
import { getLearningPathRepository } from '../../data/repositories.js';
|
||||
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||
import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js';
|
||||
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
|
||||
import { mapToTeacher } from '../../interfaces/teacher.js';
|
||||
import { Collection } from '@mikro-orm/core';
|
||||
|
||||
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
|
||||
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
|
||||
|
||||
export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): LearningPathEntity {
|
||||
const admins = adminsDto.map((admin) => mapToTeacher(admin));
|
||||
const repo = getLearningPathRepository();
|
||||
const path = repo.create({
|
||||
hruid: dto.hruid,
|
||||
language: dto.language as Language,
|
||||
description: dto.description,
|
||||
title: dto.title,
|
||||
admins,
|
||||
image: dto.image ? Buffer.from(base64ToArrayBuffer(dto.image)) : null,
|
||||
});
|
||||
const nodes = dto.nodes.map((nodeDto: LearningObjectNode, i: number) =>
|
||||
repo.createNode({
|
||||
learningPath: path,
|
||||
learningObjectHruid: nodeDto.learningobject_hruid,
|
||||
nodeNumber: i,
|
||||
language: nodeDto.language,
|
||||
version: nodeDto.version,
|
||||
startNode: nodeDto.start_node ?? false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
);
|
||||
dto.nodes.forEach((nodeDto) => {
|
||||
const fromNode = nodes.find(
|
||||
(it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version
|
||||
)!;
|
||||
const transitions = nodeDto.transitions
|
||||
.map((transDto, i) => {
|
||||
const toNode = nodes.find(
|
||||
(it) =>
|
||||
it.learningObjectHruid === transDto.next.hruid &&
|
||||
it.language === transDto.next.language &&
|
||||
it.version === transDto.next.version
|
||||
);
|
||||
|
||||
if (toNode) {
|
||||
return repo.createTransition({
|
||||
transitionNumber: i,
|
||||
node: fromNode,
|
||||
next: toNode,
|
||||
condition: transDto.condition ?? 'true',
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter((it) => it)
|
||||
.map((it) => it!);
|
||||
|
||||
fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions);
|
||||
});
|
||||
|
||||
path.nodes = new Collection<LearningPathNode>(path, nodes);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api)
|
||||
*/
|
||||
|
@ -19,12 +84,7 @@ const learningPathService = {
|
|||
* @param source
|
||||
* @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned.
|
||||
*/
|
||||
async fetchLearningPaths(
|
||||
hruids: string[],
|
||||
language: Language,
|
||||
source: string,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
): Promise<LearningPathResponse> {
|
||||
async fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: Group): Promise<LearningPathResponse> {
|
||||
const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix));
|
||||
const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix));
|
||||
|
||||
|
@ -48,12 +108,23 @@ const learningPathService = {
|
|||
/**
|
||||
* Search learning paths in the data source using the given search string.
|
||||
*/
|
||||
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
|
||||
async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> {
|
||||
const providerResponses = await Promise.all(
|
||||
allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor))
|
||||
);
|
||||
return providerResponses.flat();
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new learning path to the database.
|
||||
* @param dto Learning path DTO from which the learning path will be created.
|
||||
* @param admins Teachers who should become an admin of the learning path.
|
||||
*/
|
||||
async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> {
|
||||
const repo = getLearningPathRepository();
|
||||
const path = mapToLearningPath(dto, admins);
|
||||
await repo.save(path, { preventOverwrite: true });
|
||||
},
|
||||
};
|
||||
|
||||
export default learningPathService;
|
||||
|
|
|
@ -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';
|
||||
|
@ -37,6 +37,7 @@ export async function createSubmission(submissionDTO: SubmissionDTO): Promise<Su
|
|||
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ export class SqliteAutoincrementSubscriber implements EventSubscriber {
|
|||
|
||||
for (const prop of Object.values(args.meta.properties)) {
|
||||
const property = prop as EntityProperty<T>;
|
||||
if (property.primary && property.autoincrement && !(args.entity as Record<string, unknown>)[property.name]) {
|
||||
if (property.primary && property.autoincrement && (args.entity as Record<string, unknown>)[property.name] === undefined) {
|
||||
// Obtain and increment sequence number of this entity.
|
||||
const propertyKey = args.meta.class.name + '.' + property.name;
|
||||
const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0;
|
||||
|
|
12
backend/src/util/base64-buffer-conversion.ts
Normal file
12
backend/src/util/base64-buffer-conversion.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Convert a Base64-encoded string into a buffer with the same data.
|
||||
* @param base64 The Base64 encoded string.
|
||||
*/
|
||||
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue