Skip to content

Commit

Permalink
EW-694 Common Cartridge Course Import (#4700)
Browse files Browse the repository at this point in the history
* Adding two new feature flags
* Adding new common cartridge import module
* Adding new REST endpoint for course import
  • Loading branch information
psachmann authored Feb 23, 2024
1 parent bb68af9 commit a574bbc
Show file tree
Hide file tree
Showing 37 changed files with 1,428 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import AdmZip from 'adm-zip';
import { CommonCartridgeFileParser } from './common-cartridge-file-parser';

describe('CommonCartridgeFileParser', () => {
describe('constructor', () => {
describe('when manifest file is found', () => {
const setup = (manifestName: string) => {
const archive = new AdmZip();

archive.addFile(manifestName, Buffer.from('<manifest></manifest>'));

const file = archive.toBuffer();

return { file };
};

it('should use imsmanifest.xml as manifest', () => {
const { file } = setup('imsmanifest.xml');
const parser = new CommonCartridgeFileParser(file);

expect(parser.manifest).toBeDefined();
});

it('should use manifest.xml as manifest', () => {
const { file } = setup('manifest.xml');
const parser = new CommonCartridgeFileParser(file);

expect(parser.manifest).toBeDefined();
});
});

describe('when manifest file is not found', () => {
const setup = () => {
const archive = new AdmZip();
const file = archive.toBuffer();

return { file };
};

it('should throw', () => {
const { file } = setup();

expect(() => new CommonCartridgeFileParser(file)).toThrow('Manifest file not found');
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import AdmZip from 'adm-zip';
import { CommonCartridgeManifestParser } from './common-cartridge-manifest-parser';
import { CommonCartridgeManifestNotFoundException } from './utils/common-cartridge-manifest-not-found.exception';

export class CommonCartridgeFileParser {
private readonly manifestParser: CommonCartridgeManifestParser;

public constructor(file: Buffer) {
const archive = new AdmZip(file);

this.manifestParser = new CommonCartridgeManifestParser(this.getManifestFileAsString(archive));
}

public get manifest(): CommonCartridgeManifestParser {
return this.manifestParser;
}

private getManifestFileAsString(archive: AdmZip): string | never {
// imsmanifest.xml is the standard name, but manifest.xml is also valid until v1.3
const manifest = archive.getEntry('imsmanifest.xml') || archive.getEntry('manifest.xml');

if (manifest) {
return archive.readAsText(manifest);
}

throw new CommonCartridgeManifestNotFoundException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import AdmZip from 'adm-zip';
import { readFile } from 'fs/promises';
import { CommonCartridgeManifestParser } from './common-cartridge-manifest-parser';

describe('CommonCartridgeManifestParser', () => {
const setupFile = async (loadFile: boolean) => {
if (!loadFile) {
const sut = new CommonCartridgeManifestParser('<manifest></manifest>');

return { sut };
}

const buffer = await readFile('./apps/server/test/assets/common-cartridge/us_history_since_1877.imscc');
const archive = new AdmZip(buffer);
const sut = new CommonCartridgeManifestParser(archive.readAsText('imsmanifest.xml'));

return { sut };
};

describe('getSchema', () => {
describe('when schema is present', () => {
const setup = async () => setupFile(true);

it('should return the schema', async () => {
const { sut } = await setup();
const result = sut.getSchema();

expect(result).toBe('IMS Common Cartridge');
});
});

describe('when schema is not present', () => {
const setup = async () => setupFile(false);

it('should return undefined', async () => {
const { sut } = await setup();
const result = sut.getSchema();

expect(result).toBeUndefined();
});
});
});

describe('getVersion', () => {
describe('when version is present', () => {
const setup = async () => setupFile(true);

it('should return the version', async () => {
const { sut } = await setup();
const result = sut.getVersion();

expect(result).toBe('1.3.0');
});
});

describe('when version is not present', () => {
const setup = async () => setupFile(false);

it('should return undefined', async () => {
const { sut } = await setup();
const result = sut.getVersion();

expect(result).toBeUndefined();
});
});
});

describe('getTitle', () => {
describe('when title is present', () => {
const setup = async () => setupFile(true);

it('should return the title', async () => {
const { sut } = await setup();
const result = sut.getTitle();

expect(result).toBe('201510-AMH-2020-70C-12218-US History Since 1877');
});
});

describe('when title is not present', () => {
const setup = async () => setupFile(false);

it('should return null', async () => {
const { sut } = await setup();
const result = sut.getTitle();

expect(result).toBeUndefined();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { JSDOM } from 'jsdom';

export class CommonCartridgeManifestParser {
private readonly doc: Document;

public constructor(manifest: string) {
this.doc = new JSDOM(manifest, { contentType: 'text/xml' }).window.document;
}

public getSchema(): string | undefined {
const result = this.doc.querySelector('manifest > metadata > schema');

return result?.textContent ?? undefined;
}

public getVersion(): string | undefined {
const result = this.doc.querySelector('manifest > metadata > schemaversion');

return result?.textContent ?? undefined;
}

public getTitle(): string | undefined {
const result = this.doc.querySelector('manifest > metadata > lom > general > title > string');

return result?.textContent ?? undefined;
}
}
1 change: 1 addition & 0 deletions apps/server/src/modules/common-cartridge/import/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CommonCartridgeFileParser } from './common-cartridge-file-parser';
11 changes: 11 additions & 0 deletions apps/server/src/modules/common-cartridge/import/jsdom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// This is a workaround for the missing types for jsdom, because the types are not included in the package itself.
// This is a declaration file for the JSDOM class, which is used in the CommonCartridgeManifestParser.
// Currently the JSDOM types are bit buggy and don't work properly with our project setup.

declare module 'jsdom' {
class JSDOM {
constructor(html: string, options?: Record<string, unknown>);

window: Window;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CommonCartridgeManifestNotFoundException } from './common-cartridge-manifest-not-found.exception';

describe('CommonCartridgeManifestNotFoundException', () => {
describe('getLogMessage', () => {
describe('when returning a message', () => {
const setup = () => {
const sut = new CommonCartridgeManifestNotFoundException();

return { sut };
};

it('should contain the type', () => {
const { sut } = setup();

const result = sut.getLogMessage();

expect(result.type).toEqual('WRONG_FILE_FORMAT');
});

it('should contain the stack', () => {
const { sut } = setup();

const result = sut.getLogMessage();

expect(result.stack).toEqual(sut.stack);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import { ErrorLogMessage, Loggable } from '@src/core/logger';

export class CommonCartridgeManifestNotFoundException extends BadRequestException implements Loggable {
constructor() {
super('Manifest file not found.');
}

public getLogMessage(): ErrorLogMessage {
const message: ErrorLogMessage = {
type: 'WRONG_FILE_FORMAT',
stack: this.stack,
};

return message;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { INestApplication, StreamableFile } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Permission } from '@shared/domain/interface';
import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, courseFactory } from '@shared/testing';
import { readFile } from 'node:fs/promises';

const createStudent = () => {
const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]);
Expand Down Expand Up @@ -51,6 +52,7 @@ describe('Course Controller (API)', () => {

return { student, course, teacher };
};

it('should find courses as student', async () => {
const { student, course } = setup();
await em.persistAndFlush([student.account, student.user, course]);
Expand All @@ -65,6 +67,7 @@ describe('Course Controller (API)', () => {
expect(data[0].startDate).toBe(course.startDate);
expect(data[0].untilDate).toBe(course.untilDate);
});

it('should find courses as teacher', async () => {
const { teacher, course } = setup();
await em.persistAndFlush([teacher.account, teacher.user, course]);
Expand Down Expand Up @@ -96,6 +99,7 @@ describe('Course Controller (API)', () => {

return { course, teacher, teacherUnkownToCourse, substitutionTeacher, student1 };
};

it('should find course export', async () => {
const { teacher, course } = setup();
await em.persistAndFlush([teacher.account, teacher.user, course]);
Expand All @@ -114,4 +118,27 @@ describe('Course Controller (API)', () => {
expect(response.header['content-disposition']).toBe('attachment;');
});
});

describe('[POST] /courses/import', () => {
const setup = async () => {
const teacher = createTeacher();
const course = await readFile('./apps/server/test/assets/common-cartridge/us_history_since_1877.imscc');
const courseFileName = 'us_history_since_1877.imscc';

await em.persistAndFlush([teacher.account, teacher.user]);
em.clear();

const loggedInClient = await testApiClient.login(teacher.account);

return { loggedInClient, course, courseFileName };
};

it('should import course', async () => {
const { loggedInClient, course, courseFileName } = await setup();

const response = await loggedInClient.postWithAttachment('import', 'file', course, courseFileName);

expect(response.statusCode).toEqual(201);
});
});
});
45 changes: 42 additions & 3 deletions apps/server/src/modules/learnroom/controller/course.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication';
import { Controller, Get, NotFoundException, Param, Query, Res, StreamableFile } from '@nestjs/common';
import {
Controller,
Get,
NotFoundException,
Param,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBadRequestResponse,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiInternalServerErrorResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { PaginationParams } from '@shared/controller/';
import { Response } from 'express';
import { CourseMapper } from '../mapper/course.mapper';
import { CourseImportUc } from '../uc';
import { CourseExportUc } from '../uc/course-export.uc';
import { CourseUc } from '../uc/course.uc';
import { CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto';
import { CommonCartridgeFileValidatorPipe } from '../utils';
import { CourseImportBodyParams, CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto';

@ApiTags('Courses')
@Authenticate('jwt')
Expand All @@ -16,6 +38,7 @@ export class CourseController {
constructor(
private readonly courseUc: CourseUc,
private readonly courseExportUc: CourseExportUc,
private readonly courseImportUc: CourseImportUc,
private readonly configService: ConfigService
) {}

Expand Down Expand Up @@ -47,4 +70,20 @@ export class CourseController {
});
return new StreamableFile(result);
}

@Post('import')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Imports a course from a Common Cartridge file.' })
@ApiConsumes('multipart/form-data')
@ApiBody({ type: CourseImportBodyParams, required: true })
@ApiCreatedResponse({ description: 'Course was successfully imported.' })
@ApiBadRequestResponse({ description: 'Request data has invalid format.' })
@ApiInternalServerErrorResponse({ description: 'Internal server error.' })
public async importCourse(
@CurrentUser() currentUser: ICurrentUser,
@UploadedFile(CommonCartridgeFileValidatorPipe)
file: Express.Multer.File
): Promise<void> {
await this.courseImportUc.importFromCommonCartridge(currentUser.userId, file.buffer);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';

export class CourseImportBodyParams {
@ApiProperty({
type: String,
format: 'binary',
required: true,
description: 'The Common Cartridge file to import.',
})
file!: Express.Multer.File;
}
Loading

0 comments on commit a574bbc

Please sign in to comment.