Skip to content

Commit

Permalink
Merge pull request doubtfire-lms#877 from doubtfire-lms/new/scorm
Browse files Browse the repository at this point in the history
New/scorm
  • Loading branch information
macite authored Oct 25, 2024
2 parents 3f1a752 + d16d831 commit 95e24f9
Show file tree
Hide file tree
Showing 48 changed files with 1,841 additions and 55 deletions.
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ services:
RAILS_ENV: 'development'

DF_STUDENT_WORK_DIR: /student-work
DF_INSTITUTION_HOST: http://localhost:3000
DF_INSTITUTION_HOST: http://localhost:4200
DF_INSTITUTION_PRODUCT_NAME: OnTrack

DF_SECRET_KEY_BASE: test-secret-key-test-secret-key!
Expand All @@ -24,8 +24,8 @@ services:
# Authentication method - can set to AAF or ldap
DF_AUTH_METHOD: database
DF_AAF_ISSUER_URL: https://rapid.test.aaf.edu.au
DF_AAF_AUDIENCE_URL: http://localhost:3000
DF_AAF_CALLBACK_URL: http://localhost:3000/api/auth/jwt
DF_AAF_AUDIENCE_URL: http://localhost:4200
DF_AAF_CALLBACK_URL: http://localhost:4200/api/auth/jwt
DF_AAF_IDENTITY_PROVIDER_URL: https://signon-uat.deakin.edu.au/idp/shibboleth
DF_AAF_UNIQUE_URL: https://rapid.test.aaf.edu.au/jwt/authnrequest/research/Ag4EJJhjf0zXHqlKvKZEbg
DF_AAF_AUTH_SIGNOUT_URL: https://sync-uat.deakin.edu.au/auth/logout
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build:angular17": "ng build",
"lint:fix": "ng lint --fix",
"lint": "ng lint",
"serve:angular17": "export NODE_OPTIONS=--max_old_space_size=4096 && ng serve --configuration $NODE_ENV",
"serve:angular17": "export NODE_OPTIONS=--max_old_space_size=4096 && ng serve --configuration $NODE_ENV --proxy-config proxy.conf.json",
"start": "npm-run-all -l -s build:angular1 -p watch:angular1 serve:angular17",
"watch:angular1": "grunt delta",
"deploy:build2api": "ng build --delete-output-path=true --optimization=true --configuration production --output-path dist",
Expand Down
6 changes: 6 additions & 0 deletions proxy.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}
6 changes: 6 additions & 0 deletions src/app/api/models/doubtfire-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export * from './task-comment/discussion-comment';
export * from '../services/task-outcome-alignment.service';
export * from './task-similarity';
export * from './tii-action';
export * from './scorm-datamodel';
export * from './scorm-player-context';
export * from './test-attempt';
export * from './task-comment/scorm-comment';
export * from './task-comment/scorm-extension-comment';

// Users -- are students or staff
export * from './user/user';
Expand All @@ -56,3 +61,4 @@ export * from '../services/teaching-period-break.service';
export * from '../services/learning-outcome.service';
export * from '../services/group-set.service';
export * from '../services/task-similarity.service';
export * from '../services/test-attempt.service';
50 changes: 50 additions & 0 deletions src/app/api/models/scorm-datamodel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export class ScormDataModel {
dataModel: {[key: string]: any} = {};
readonly msgPrefix = 'SCORM DataModel: ';

constructor() {
this.dataModel = {};
}

public restore(dataModel: string) {
// console.log(this.msgPrefix + 'restoring DataModel with provided data');
this.dataModel = JSON.parse(dataModel);
}

public get(key: string): string {
// console.log(`SCORM DataModel: get ${key} ${this.dataModel[key]}`);
return this.dataModel[key] ?? '';
}

public dump(): {[key: string]: any} {
return this.dataModel;
}

public set(key: string, value: any): string {
// console.log(this.msgPrefix + 'set: ', key, value);
this.dataModel[key] = value;
if (key.match('cmi.interactions.\\d+.id')) {
// cmi.interactions._count must be incremented after a new interaction is created
const interactionPath = key.match('cmi.interactions.\\d+');
const objectivesCounterForInteraction = interactionPath.toString() + '.objectives._count';
// console.log('Incrementing cmi.interactions._count');
this.dataModel['cmi.interactions._count']++;
// cmi.interactions.n.objectives._count must be initialized after an interaction is created
// console.log(`Initializing ${objectivesCounterForInteraction}`);
this.dataModel[objectivesCounterForInteraction] = 0;
}
if (key.match('cmi.interactions.\\d+.objectives.\\d+.id')) {
const interactionPath = key.match('cmi.interactions.\\d+.objectives');
const objectivesCounterForInteraction = interactionPath.toString() + '._count';
// cmi.interactions.n.objectives._count must be incremented after objective creation
// console.log(`Incrementing ${objectivesCounterForInteraction}`);
this.dataModel[objectivesCounterForInteraction.toString()]++;
}
if (key.match('cmi.objectives.\\d+.id')) {
// cmi.objectives._count must be incremented after a new objective is created
// console.log('Incrementing cmi.objectives._count');
this.dataModel['cmi.objectives._count']++;
}
return 'true';
}
}
66 changes: 66 additions & 0 deletions src/app/api/models/scorm-player-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {User} from 'src/app/api/models/doubtfire-model';

type DataModelState = 'Uninitialized' | 'Initialized' | 'Terminated';

type DataModelError = Record<number, string>;
const CMIErrorCodes: DataModelError = {
0: 'No Error',
101: 'General Exception',
102: 'General Initialization Failure',
103: 'Already Initialized',
104: 'Content Instance Terminated',
111: 'General Termination Failure',
112: 'Termination Before Initialization',
113: 'Termination After Termination',
122: 'Retrieve Data Before Initialization',
123: 'Retrieve Data After Termination',
132: 'Store Data Before Initialization',
133: 'Store Data After Termination',
142: 'Commit Before Initialization',
143: 'Commit After Termination',
201: 'General Argument Error',
301: 'General Get Failure',
351: 'General Set Failure',
391: 'General Commit Failure',
401: 'Undefined Data Model Element',
402: 'Unimplemented Data Model Element',
403: 'Data Model Element Value Not Initialized',
404: 'Data Model Element Is Read Only',
405: 'Data Model Element Is Write Only',
406: 'Data Model Element Type Mismatch',
407: 'Data Model Element Value Out Of Range',
408: 'Data Model Dependency Not Established',
};

export class ScormPlayerContext {
mode: 'browse' | 'normal' | 'review' | 'preview';
state: DataModelState;

private _errorCode: number;
get errorCode() {
return this._errorCode;
}
set errorCode(value: number) {
this._errorCode = value;
}

getErrorMessage(value: string): string {
return CMIErrorCodes[value];
}

projectId: number;
taskDefId: number;
user: User;

attemptId: number;
learnerName: string;
learnerId: number;

constructor(user: User) {
this.user = user;
this.learnerId = user.id;
this.learnerName = user.firstName + ' ' + user.lastName;
this.state = 'Uninitialized';
this.errorCode = 0;
}
}
12 changes: 12 additions & 0 deletions src/app/api/models/task-comment/scorm-comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {Task, TaskComment, TestAttempt} from '../doubtfire-model';

export class ScormComment extends TaskComment {
testAttempt: TestAttempt;

// UI rendering data
lastInScormSeries: boolean = false;

constructor(task: Task) {
super(task);
}
}
33 changes: 33 additions & 0 deletions src/app/api/models/task-comment/scorm-extension-comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {Observable} from 'rxjs';
import {tap} from 'rxjs/operators';
import {AppInjector} from 'src/app/app-injector';
import {TaskCommentService} from '../../services/task-comment.service';
import {TaskComment, Task} from '../doubtfire-model';

export class ScormExtensionComment extends TaskComment {
assessed: boolean;
granted: boolean;
dateAssessed: Date;
taskScormExtensions: number;

constructor(task: Task) {
super(task);
}

private assessScormExtension(): Observable<TaskComment> {
const tcs: TaskCommentService = AppInjector.get(TaskCommentService);
return tcs.assessScormExtension(this).pipe(
tap((tc: TaskComment) => {
const scormExtension: ScormExtensionComment = tc as ScormExtensionComment;

const task = tc.task;
task.scormExtensions = scormExtension.taskScormExtensions;
}),
);
}

public grant(): Observable<TaskComment> {
this.granted = true;
return this.assessScormExtension();
}
}
31 changes: 31 additions & 0 deletions src/app/api/models/task-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export class TaskDefinition extends Entity {
groupSet: GroupSet = null;
hasTaskSheet: boolean;
hasTaskResources: boolean;
scormEnabled: boolean;
hasScormData: boolean;
scormAllowReview: boolean;
scormBypassTest: boolean;
scormTimeDelayEnabled: boolean;
scormAttemptLimit: number = 0;
hasTaskAssessmentResources: boolean;
isGraded: boolean;
maxQualityPts: number;
Expand Down Expand Up @@ -152,6 +158,20 @@ export class TaskDefinition extends Entity {
}`;
}

public getScormDataUrl(asAttachment: boolean = false) {
const constants = AppInjector.get(DoubtfireConstants);
return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/scorm_data.json${
asAttachment ? '?as_attachment=true' : ''
}`;
}

/**
* Open the SCORM test in a new tab - using preview mode.
*/
public previewScormTest(): void {
window.open(`#/task_def_id/${this.id}/preview-scorm`, '_blank');
}

public get targetGradeText(): string {
return Grade.GRADES[this.targetGrade];
}
Expand All @@ -176,6 +196,12 @@ export class TaskDefinition extends Entity {
}/task_resources`;
}

public get scormDataUploadUrl(): string {
return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${
this.id
}/scorm_data`;
}

public get taskAssessmentResourcesUploadUrl(): string {
return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${
this.id
Expand All @@ -198,6 +224,11 @@ export class TaskDefinition extends Entity {
return httpClient.delete(this.taskResourcesUploadUrl).pipe(tap(() => (this.hasTaskResources = false)));
}

public deleteScormData(): Observable<any> {
const httpClient = AppInjector.get(HttpClient);
return httpClient.delete(this.scormDataUploadUrl).pipe(tap(() => (this.hasScormData = false)));
}

public deleteTaskAssessmentResources(): Observable<any> {
const httpClient = AppInjector.get(HttpClient);
return httpClient
Expand Down
64 changes: 62 additions & 2 deletions src/app/api/models/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
TaskCommentService,
TaskSimilarity,
TaskSimilarityService,
TestAttempt,
TestAttemptService,
ScormComment,
} from './doubtfire-model';
import {Grade} from './grade';
import {LOCALE_ID} from '@angular/core';
Expand All @@ -30,6 +33,7 @@ export class Task extends Entity {
status: TaskStatusEnum = 'not_started';
dueDate: Date;
extensions: number;
scormExtensions: number;
submissionDate: Date;
completionDate: Date;
timesAssessed: number;
Expand All @@ -53,6 +57,7 @@ export class Task extends Entity {
public readonly commentCache: EntityCache<TaskComment> = new EntityCache<TaskComment>();

public readonly similarityCache: EntityCache<TaskSimilarity> = new EntityCache<TaskSimilarity>();
public readonly testAttemptCache: EntityCache<TestAttempt> = new EntityCache<TestAttempt>();

private _unit: Unit;

Expand Down Expand Up @@ -381,6 +386,14 @@ export class Task extends Entity {
if (comments[i].replyToId) {
comments[i].originalComment = comments.find((tc) => tc.id === comments[i].replyToId);
}

// Scorm series
if (comments[i].commentType === 'scorm') {
comments[i].firstInSeries = i === 0 || comments[i - 1].commentType !== 'scorm';
(comments[i] as ScormComment).lastInScormSeries =
i + 1 === comments.length || comments[i + 1]?.commentType !== 'scorm';
if (!comments[i].firstInSeries) comments[i].shouldShowTimestamp = false;
}
}

comments[comments.length - 1].shouldShowAvatar = true;
Expand All @@ -396,7 +409,7 @@ export class Task extends Entity {

public taskKeyToIdString(): string {
const key = this.taskKey();
return `task-key-${key.studentId}-${key.taskDefAbbr}`.replace(/[.#]/g, '-');
return `task-key-${key.studentId}-${key.taskDefAbbr}`.replace(/[.# ]/g, '-');
}

public get similaritiesDetected(): boolean {
Expand Down Expand Up @@ -507,6 +520,33 @@ export class Task extends Entity {
);
}

public get scormEnabled(): boolean {
return this.definition.scormEnabled && this.definition.hasScormData;
}

public get scormPassed(): boolean {
if (this.latestCompletedTestAttempt) {
return this.latestCompletedTestAttempt.successStatus;
}
return false;
}

/**
* Launch the SCORM player for this task in a new window.
*/
public launchScormPlayer(): void {
const url = `#/projects/${this.project.id}/task_def_id/${this.taskDefId}/scorm-player/normal`;
window.open(url, '_blank');
}

public get isReadyForUpload(): boolean {
return !this.scormEnabled || this.definition.scormBypassTest || this.scormPassed;
}

public get latestCompletedTestAttempt(): TestAttempt {
return this.testAttemptCache.currentValues.find((attempt) => attempt.terminated);
}

public submissionUrl(asAttachment: boolean = false): string {
return `${AppInjector.get(DoubtfireConstants).API_URL}/projects/${
this.project.id
Expand Down Expand Up @@ -659,12 +699,15 @@ export class Task extends Entity {

public triggerTransition(status: TaskStatusEnum): void {
if (this.status === status) return;
const alerts: AlertService = AppInjector.get(AlertService);

const requiresFileUpload =
['ready_for_feedback', 'need_help'].includes(status) && this.requiresFileUpload();

if (requiresFileUpload) {
if (requiresFileUpload && this.isReadyForUpload) {
this.presentTaskSubmissionModal(status);
} else if (requiresFileUpload && !this.isReadyForUpload) {
alerts.error('Complete Knowledge Check first to submit files', 6000);
} else {
this.updateTaskStatus(status);
}
Expand Down Expand Up @@ -764,4 +807,21 @@ export class Task extends Entity {
},
);
}

/**
* Fetch the SCORM test attempts for this task.
*/
public fetchTestAttempts(): Observable<TestAttempt[]> {
const testAttemptService: TestAttemptService = AppInjector.get(TestAttemptService);
return testAttemptService.query(
{
project_id: this.project.id,
task_def_id: this.taskDefId,
},
{
cache: this.testAttemptCache,
constructorParams: this,
},
);
}
}
Loading

0 comments on commit 95e24f9

Please sign in to comment.