diff --git a/docker-compose.yml b/docker-compose.yml
index b97ff4e52..9019dbcc4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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!
@@ -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
diff --git a/package.json b/package.json
index 15405c0da..eb6a1b46b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/proxy.conf.json b/proxy.conf.json
new file mode 100644
index 000000000..63dd62750
--- /dev/null
+++ b/proxy.conf.json
@@ -0,0 +1,6 @@
+{
+ "/api": {
+ "target": "http://localhost:3000",
+ "secure": false
+ }
+}
diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts
index 3cb491108..fffc361a4 100644
--- a/src/app/api/models/doubtfire-model.ts
+++ b/src/app/api/models/doubtfire-model.ts
@@ -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';
@@ -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';
diff --git a/src/app/api/models/scorm-datamodel.ts b/src/app/api/models/scorm-datamodel.ts
new file mode 100644
index 000000000..0fdc2d3a0
--- /dev/null
+++ b/src/app/api/models/scorm-datamodel.ts
@@ -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';
+ }
+}
diff --git a/src/app/api/models/scorm-player-context.ts b/src/app/api/models/scorm-player-context.ts
new file mode 100644
index 000000000..e8eed9b12
--- /dev/null
+++ b/src/app/api/models/scorm-player-context.ts
@@ -0,0 +1,66 @@
+import {User} from 'src/app/api/models/doubtfire-model';
+
+type DataModelState = 'Uninitialized' | 'Initialized' | 'Terminated';
+
+type DataModelError = Record;
+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;
+ }
+}
diff --git a/src/app/api/models/task-comment/scorm-comment.ts b/src/app/api/models/task-comment/scorm-comment.ts
new file mode 100644
index 000000000..b15a2c50c
--- /dev/null
+++ b/src/app/api/models/task-comment/scorm-comment.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/models/task-comment/scorm-extension-comment.ts b/src/app/api/models/task-comment/scorm-extension-comment.ts
new file mode 100644
index 000000000..b8aac7909
--- /dev/null
+++ b/src/app/api/models/task-comment/scorm-extension-comment.ts
@@ -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 {
+ 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 {
+ this.granted = true;
+ return this.assessScormExtension();
+ }
+}
diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts
index 0766f85eb..b84868676 100644
--- a/src/app/api/models/task-definition.ts
+++ b/src/app/api/models/task-definition.ts
@@ -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;
@@ -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];
}
@@ -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
@@ -198,6 +224,11 @@ export class TaskDefinition extends Entity {
return httpClient.delete(this.taskResourcesUploadUrl).pipe(tap(() => (this.hasTaskResources = false)));
}
+ public deleteScormData(): Observable {
+ const httpClient = AppInjector.get(HttpClient);
+ return httpClient.delete(this.scormDataUploadUrl).pipe(tap(() => (this.hasScormData = false)));
+ }
+
public deleteTaskAssessmentResources(): Observable {
const httpClient = AppInjector.get(HttpClient);
return httpClient
diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts
index 2aa167adf..f5019d4ac 100644
--- a/src/app/api/models/task.ts
+++ b/src/app/api/models/task.ts
@@ -15,6 +15,9 @@ import {
TaskCommentService,
TaskSimilarity,
TaskSimilarityService,
+ TestAttempt,
+ TestAttemptService,
+ ScormComment,
} from './doubtfire-model';
import {Grade} from './grade';
import {LOCALE_ID} from '@angular/core';
@@ -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;
@@ -53,6 +57,7 @@ export class Task extends Entity {
public readonly commentCache: EntityCache = new EntityCache();
public readonly similarityCache: EntityCache = new EntityCache();
+ public readonly testAttemptCache: EntityCache = new EntityCache();
private _unit: Unit;
@@ -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;
@@ -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 {
@@ -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
@@ -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);
}
@@ -764,4 +807,21 @@ export class Task extends Entity {
},
);
}
+
+ /**
+ * Fetch the SCORM test attempts for this task.
+ */
+ public fetchTestAttempts(): Observable {
+ const testAttemptService: TestAttemptService = AppInjector.get(TestAttemptService);
+ return testAttemptService.query(
+ {
+ project_id: this.project.id,
+ task_def_id: this.taskDefId,
+ },
+ {
+ cache: this.testAttemptCache,
+ constructorParams: this,
+ },
+ );
+ }
}
diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts
new file mode 100644
index 000000000..4497028a7
--- /dev/null
+++ b/src/app/api/models/test-attempt.ts
@@ -0,0 +1,28 @@
+import {Entity} from 'ngx-entity-service';
+import {Task} from './doubtfire-model';
+
+export class TestAttempt extends Entity {
+ id: number;
+ terminated: boolean;
+ completionStatus: boolean;
+ successStatus: boolean;
+ scoreScaled: number;
+ cmiDatamodel: string;
+ attemptedTime: Date;
+
+ task: Task;
+
+ constructor(task: Task) {
+ super();
+ this.task = task;
+ }
+
+ /**
+ * Open a test attempt window in review mode
+ */
+ public review() {
+ const url = `#/projects/${this.task.project.id}/task_def_id/${this.task.taskDefId}/scorm-player/review/${this.id}`;
+
+ window.open(url, '_blank');
+ }
+}
diff --git a/src/app/api/services/authentication.service.ts b/src/app/api/services/authentication.service.ts
index b2cb12f40..a83637b23 100644
--- a/src/app/api/services/authentication.service.ts
+++ b/src/app/api/services/authentication.service.ts
@@ -191,4 +191,12 @@ export class AuthenticationService {
setTimeout(() => this.router.stateService.go('timeout'), 500);
}
}
+
+ public getScormToken(): Observable {
+ return this.httpClient.get(this.AUTH_URL + '/scorm').pipe(
+ map((response) => {
+ return response['scorm_auth_token'];
+ }),
+ );
+ }
}
diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts
new file mode 100644
index 000000000..0f1af9889
--- /dev/null
+++ b/src/app/api/services/scorm-adapter.service.ts
@@ -0,0 +1,283 @@
+import {Injectable} from '@angular/core';
+import {UserService} from './user.service';
+import API_URL from 'src/app/config/constants/apiURL';
+import {ScormDataModel, ScormPlayerContext} from 'src/app/api/models/doubtfire-model';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ScormAdapterService {
+ private dataModel: ScormDataModel;
+ private context: ScormPlayerContext;
+ private xhr: XMLHttpRequest;
+
+ constructor(private userService: UserService) {
+ this.dataModel = new ScormDataModel();
+ this.context = new ScormPlayerContext(this.userService.currentUser);
+ this.xhr = new XMLHttpRequest();
+ }
+
+ set projectId(projectId: number) {
+ this.context.projectId = projectId;
+ }
+
+ set taskDefId(taskDefId: number) {
+ this.context.taskDefId = taskDefId;
+ }
+
+ set mode(mode: 'browse' | 'normal' | 'review' | 'preview') {
+ this.context.mode = mode;
+ }
+
+ set testAttemptId(testAttemptId: number) {
+ this.context.attemptId = testAttemptId;
+ }
+
+ get state() {
+ return this.context.state;
+ }
+
+ destroy() {
+ this.dataModel = new ScormDataModel();
+ this.context.state = 'Uninitialized';
+ }
+
+ Initialize(): string {
+ // console.log('API_1484_11: Initialize');
+
+ // TODO: error handling and reporting
+ switch (this.context.state) {
+ case 'Initialized':
+ this.context.errorCode = 103;
+ // console.log('Already Initialized');
+ break;
+ case 'Terminated':
+ this.context.errorCode = 104;
+ // console.log('Content Instance Terminated');
+ break;
+ }
+
+ if (this.context.mode === 'review') {
+ this.xhr.open('GET', `${API_URL}/test_attempts/${this.context.attemptId}/review`, false);
+ this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken);
+ this.xhr.setRequestHeader('Username', this.context.user.username);
+
+ this.xhr.send();
+ // console.log(this.xhr.responseText);
+
+ const reviewSession = JSON.parse(this.xhr.responseText);
+ this.dataModel.restore(reviewSession.cmi_datamodel);
+ // console.log(this.dataModel.dump());
+
+ this.context.state = 'Initialized';
+ return 'true';
+ }
+
+ // TODO: move this part into the player component
+ this.xhr.open(
+ 'GET',
+ `${API_URL}/projects/${this.context.projectId}/task_def_id/${this.context.taskDefId}/test_attempts/latest`,
+ false,
+ );
+ this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken);
+ this.xhr.setRequestHeader('Username', this.context.user.username);
+
+ let noTestFound = false;
+ let startNewTest = false;
+
+ this.xhr.onload = () => {
+ if (this.xhr.status == 404) {
+ noTestFound = true;
+ }
+
+ // if (this.xhr.status >= 200 && this.xhr.status < 400) {
+ // console.log('Retrieved the latest attempt.');
+ // } else if (this.xhr.status == 404) {
+ // console.log('Not found.');
+ // noTestFound = true;
+ // } else {
+ // console.error('Error saving DataModel:', this.xhr.responseText);
+ // }
+ };
+
+ this.xhr.send();
+ // console.log(this.xhr.responseText);
+
+ if (!noTestFound) {
+ const latestSession = JSON.parse(this.xhr.responseText);
+ // console.log('Latest exam session:', latestSession);
+ this.context.attemptId = latestSession.id;
+ if (latestSession.completion_status) {
+ startNewTest = true;
+ }
+ } else {
+ startNewTest = true;
+ }
+
+ if (!startNewTest) {
+ this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, false);
+ this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken);
+ this.xhr.setRequestHeader('Username', this.context.user.username);
+ this.xhr.send();
+ // console.log(this.xhr.responseText);
+
+ const currentSession = JSON.parse(this.xhr.responseText);
+ // console.log('Current exam session:', currentSession);
+ this.context.attemptId = currentSession.id;
+ this.dataModel.restore(currentSession.cmi_datamodel);
+ // console.log(this.dataModel.dump());
+ } else {
+ this.xhr.open(
+ 'POST',
+ `${API_URL}/projects/${this.context.projectId}/task_def_id/${this.context.taskDefId}/test_attempts`,
+ false,
+ );
+ this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken);
+ this.xhr.setRequestHeader('Username', this.context.user.username);
+ this.xhr.send();
+ // console.log(this.xhr.responseText);
+
+ const currentSession = JSON.parse(this.xhr.responseText);
+ // console.log('Current exam session:', currentSession);
+ this.context.attemptId = currentSession.id;
+ this.dataModel.restore(currentSession.cmi_datamodel);
+ // console.log(this.dataModel.dump());
+ }
+
+ this.context.state = 'Initialized';
+ return 'true';
+ }
+
+ Terminate(): string {
+ // console.log('API_1484_11: Terminate');
+
+ // TODO: error handling and reporting
+ switch (this.context.state) {
+ case 'Uninitialized':
+ this.context.errorCode = 112;
+ // console.log('Termination Before Initialization');
+ break;
+ case 'Terminated':
+ this.context.errorCode = 113;
+ // console.log('Termination After Termination');
+ break;
+ }
+
+ this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, false);
+ this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken);
+ this.xhr.setRequestHeader('Username', this.context.user.username);
+ this.xhr.setRequestHeader('Content-Type', 'application/json');
+ const requestData = {
+ cmi_datamodel: JSON.stringify(this.dataModel.dump()),
+ terminated: true,
+ };
+ this.xhr.send(JSON.stringify(requestData));
+ // console.log(this.xhr.responseText);
+
+ // all done, clearing datamodel and setting state to terminated
+ this.dataModel = new ScormDataModel();
+ this.context.state = 'Terminated';
+ return 'true';
+ }
+
+ GetValue(element: string): string {
+ const value = this.dataModel.get(element);
+
+ // TODO: error reporting
+ // TODO: can't get until init is done
+ switch (this.context.state) {
+ case 'Uninitialized':
+ this.context.errorCode = 122;
+ // console.log('Retrieve Data Before Initialization');
+ break;
+ case 'Terminated':
+ this.context.errorCode = 123;
+ // console.log('Retrieve Data After Termination');
+ break;
+ }
+
+ // console.log(`API_1484_11: GetValue:`, element, value);
+ return value;
+ }
+
+ SetValue(element: string, value: any): string {
+ // console.log(`API_1484_11: SetValue:`, element, value);
+
+ // TODO: error reporting
+ // TODO: can't set until init is done
+ switch (this.context.state) {
+ case 'Uninitialized':
+ this.context.errorCode = 132;
+ // console.log('Store Data Before Initialization');
+ break;
+ case 'Terminated':
+ this.context.errorCode = 133;
+ // console.log('Store Data After Termination');
+ break;
+ }
+
+ this.dataModel.set(element, value);
+ return 'true';
+ }
+
+ Commit(): string {
+ // console.log('API_1484_11: Commit');
+
+ // TODO: error reporting
+ // TODO: can't commit until init is done
+ switch (this.context.state) {
+ case 'Uninitialized':
+ this.context.errorCode = 142;
+ // console.log('Commit Before Initialization');
+ break;
+ case 'Terminated':
+ this.context.errorCode = 143;
+ // console.log('Commit After Termination');
+ break;
+ }
+
+ this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, true);
+ this.xhr.setRequestHeader('Auth-Token', this.context.user.authenticationToken);
+ this.xhr.setRequestHeader('Username', this.context.user.username);
+ this.xhr.setRequestHeader('Content-Type', 'application/json');
+ const requestData = {
+ cmi_datamodel: JSON.stringify(this.dataModel.dump()),
+ };
+
+ // this.xhr.onload = () => {
+ // if (this.xhr.status >= 200 && this.xhr.status < 400) {
+ // console.log('DataModel saved successfully.');
+ // } else {
+ // console.error('Error saving DataModel:', this.xhr.responseText);
+ // }
+ // };
+
+ // this.xhr.onerror = () => {
+ // console.error('Request failed.');
+ // };
+
+ this.xhr.send(JSON.stringify(requestData));
+ this.context.errorCode = 0;
+ return 'true';
+ }
+
+ GetLastError(): string {
+ const lastError = this.context.errorCode.toString();
+ // if (lastError !== '0') {
+ // console.log(`API_1484_11: GetLastError: ${lastError}`);
+ // }
+ return lastError;
+ }
+
+ GetErrorString(errorCode: string): string {
+ const errorString = this.context.getErrorMessage(errorCode);
+ // console.log(`API_1484_11: GetErrorString:`, errorCode, errorString);
+ return errorString;
+ }
+
+ GetDiagnostic(errorCode: string): string {
+ // TODO: implement this
+ // console.log(`API_1484_11: GetDiagnostic:`, errorCode);
+ return 'GetDiagnostic is currently not implemented';
+ }
+}
diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts
index 7fdf94bc8..4f178ce77 100644
--- a/src/app/api/services/task-comment.service.ts
+++ b/src/app/api/services/task-comment.service.ts
@@ -1,4 +1,4 @@
-import { Task, TaskComment, UserService } from 'src/app/api/models/doubtfire-model';
+import { ScormComment, Task, TaskComment, TestAttemptService, UserService } from 'src/app/api/models/doubtfire-model';
import { EventEmitter, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@@ -11,6 +11,7 @@ import API_URL from 'src/app/config/constants/apiURL';
import { EmojiService } from 'src/app/common/services/emoji.service';
import { MappingFunctions } from './mapping-fn';
import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service';
+import { ScormExtensionComment } from '../models/task-comment/scorm-extension-comment';
@Injectable()
export class TaskCommentService extends CachedEntityService {
@@ -22,6 +23,10 @@ export class TaskCommentService extends CachedEntityService {
'projects/:projectId:/task_def_id/:taskDefinitionId:/assess_extension/:id:';
private readonly requestExtensionEndpointFormat =
'projects/:projectId:/task_def_id/:taskDefinitionId:/request_extension';
+ private readonly scormExtensionGrantEndpointFormat =
+ 'projects/:projectId:/task_def_id/:taskDefinitionId:/assess_scorm_extension/:id:';
+ private readonly scormRequestExtensionEndpointFormat =
+ 'projects/:projectId:/task_def_id/:taskDefinitionId:/request_scorm_extension';
private readonly discussionCommentReplyEndpointFormat = "/projects/:project_id:/task_def_id/:task_definition_id:/comments/:task_comment_id:/discussion_comment/reply";
private readonly getDiscussionCommentPromptEndpointFormat = "/projects/:project_id:/task_def_id/:task_definition_id:/comments/:task_comment_id:/discussion_comment/prompt_number/:prompt_number:";
@@ -32,7 +37,8 @@ export class TaskCommentService extends CachedEntityService {
httpClient: HttpClient,
private emojiService: EmojiService,
private userService: UserService,
- private downloader: FileDownloaderService
+ private downloader: FileDownloaderService,
+ private testAttemptService: TestAttemptService,
) {
super(httpClient, API_URL);
@@ -85,7 +91,26 @@ export class TaskCommentService extends CachedEntityService {
'status',
'numberOfPrompts',
'timeDiscussionComplete',
- 'timeDiscussionStarted'
+ 'timeDiscussionStarted',
+
+ // Scorm Comments
+ {
+ keys: 'testAttempt',
+ toEntityFn: (data: object, key: string, comment: ScormComment) => {
+ const testAttempt = this.testAttemptService.cache.getOrCreate(
+ data[key].id,
+ testAttemptService,
+ data[key],
+ {
+ constructorParams: comment.task,
+ },
+ );
+ return testAttempt;
+ },
+ },
+
+ // Scorm Extension Comments
+ ['taskScormExtensions', 'scorm_extensions']
);
this.mapping.addJsonKey(
@@ -103,6 +128,10 @@ export class TaskCommentService extends CachedEntityService {
return new DiscussionComment(other);
case 'extension':
return new ExtensionComment(other);
+ case 'scorm':
+ return new ScormComment(other);
+ case 'scorm_extension':
+ return new ScormExtensionComment(other);
default:
return new TaskComment(other);
}
@@ -145,7 +174,7 @@ export class TaskCommentService extends CachedEntityService {
const opts: RequestOptions = { endpointFormat: this.commentEndpointFormat };
// Based on the comment type - add to the body and configure the end point
- if (commentType === 'text') {
+ if (commentType === 'text' || commentType === 'scorm') {
body.append('comment', data);
} else if (commentType === 'discussion') {
opts.endpointFormat = this.discussionEndpointFormat;
@@ -202,6 +231,39 @@ export class TaskCommentService extends CachedEntityService {
);
}
+ public assessScormExtension(extension: ScormExtensionComment): Observable {
+ const opts: RequestOptions = {
+ endpointFormat: this.scormExtensionGrantEndpointFormat,
+ entity: extension,
+ };
+
+ return super.update(
+ {
+ id: extension.id,
+ projectId: extension.project.id,
+ taskDefinitionId: extension.task.definition.id,
+ },
+ opts,
+ );
+ }
+
+ public requestScormExtension(reason: string, task: any): Observable {
+ const opts: RequestOptions = {
+ endpointFormat: this.scormRequestExtensionEndpointFormat,
+ body: {
+ comment: reason,
+ },
+ cache: task.commentCache,
+ };
+ return super.create(
+ {
+ projectId: task.project.id,
+ taskDefinitionId: task.definition.id,
+ },
+ opts,
+ );
+ }
+
public postDiscussionReply(comment: TaskComment, replyAudio: Blob): Observable{
const form = new FormData();
const pathIds = {
diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts
index b2499251e..0914929fb 100644
--- a/src/app/api/services/task-definition.service.ts
+++ b/src/app/api/services/task-definition.service.ts
@@ -99,6 +99,12 @@ export class TaskDefinitionService extends CachedEntityService {
'hasTaskSheet',
'hasTaskResources',
'hasTaskAssessmentResources',
+ 'scormEnabled',
+ 'hasScormData',
+ 'scormAllowReview',
+ 'scormBypassTest',
+ 'scormTimeDelayEnabled',
+ 'scormAttemptLimit',
'isGraded',
'maxQualityPts',
'overseerImageId',
@@ -109,7 +115,8 @@ export class TaskDefinitionService extends CachedEntityService {
'id',
'hasTaskSheet',
'hasTaskResources',
- 'hasTaskAssessmentResources'
+ 'hasTaskAssessmentResources',
+ 'hasScormData'
);
}
@@ -134,4 +141,10 @@ export class TaskDefinitionService extends CachedEntityService {
formData.append('file', file);
return AppInjector.get(HttpClient).post(taskDefinition.taskAssessmentResourcesUploadUrl, formData);
}
+
+ public uploadScormData(taskDefinition: TaskDefinition, file: File): Observable {
+ const formData = new FormData();
+ formData.append('file', file);
+ return AppInjector.get(HttpClient).post(taskDefinition.scormDataUploadUrl, formData);
+ }
}
diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts
index 344494c0e..737fe1ea4 100644
--- a/src/app/api/services/task.service.ts
+++ b/src/app/api/services/task.service.ts
@@ -46,6 +46,7 @@ export class TaskService extends CachedEntityService {
toEntityFn: MappingFunctions.mapDateToEndOfDay,
},
'extensions',
+ 'scormExtensions',
{
keys: 'submissionDate',
toEntityFn: MappingFunctions.mapDateToDay,
diff --git a/src/app/api/services/test-attempt.service.ts b/src/app/api/services/test-attempt.service.ts
new file mode 100644
index 000000000..3e2c46161
--- /dev/null
+++ b/src/app/api/services/test-attempt.service.ts
@@ -0,0 +1,88 @@
+import {Injectable} from '@angular/core';
+import {CachedEntityService} from 'ngx-entity-service';
+import API_URL from 'src/app/config/constants/apiURL';
+import {Task, TestAttempt} from 'src/app/api/models/doubtfire-model';
+import {Observable} from 'rxjs';
+import {AppInjector} from 'src/app/app-injector';
+import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants';
+import {AlertService} from 'src/app/common/services/alert.service';
+import {HttpClient} from '@angular/common/http';
+
+@Injectable()
+export class TestAttemptService extends CachedEntityService {
+ protected readonly endpointFormat =
+ 'projects/:project_id:/task_def_id/:task_def_id:/test_attempts';
+ protected readonly latestCompletedEndpoint =
+ this.endpointFormat + '/latest?completed=:completed:';
+
+ constructor(httpClient: HttpClient) {
+ super(httpClient, API_URL);
+
+ this.mapping.addKeys(
+ 'id',
+ 'terminated',
+ 'completionStatus',
+ 'successStatus',
+ 'scoreScaled',
+ 'cmiDatamodel',
+ 'attemptedTime',
+ );
+ }
+
+ public override createInstanceFrom(_json: object, constructorParams: Task): TestAttempt {
+ return new TestAttempt(constructorParams);
+ }
+
+ public getLatestCompletedAttempt(task: Task): Observable {
+ return this.get(
+ {
+ project_id: task.project.id,
+ task_def_id: task.taskDefId,
+ completed: true,
+ },
+ {
+ endpointFormat: this.latestCompletedEndpoint,
+ constructorParams: task,
+ },
+ );
+ }
+
+ public overrideSuccessStatus(testAttemptId: number, successStatus: boolean): void {
+ const http = AppInjector.get(HttpClient);
+
+ http
+ .patch(
+ `${AppInjector.get(DoubtfireConstants).API_URL}/test_attempts/${testAttemptId}?success_status=${successStatus}`,
+ {},
+ )
+ .subscribe({
+ next: (_data) => {
+ (AppInjector.get(AlertService) as AlertService).success(
+ 'Attempt pass status successfully overridden.',
+ 6000,
+ );
+ },
+ error: (message) => {
+ (AppInjector.get(AlertService) as AlertService).error(message, 6000);
+ },
+ });
+ }
+
+ public deleteAttempt(testAttemptId: number): void {
+ const http = AppInjector.get(HttpClient);
+
+ http
+ .delete(`${AppInjector.get(DoubtfireConstants).API_URL}/test_attempts/${testAttemptId}`, {})
+ .subscribe({
+ next: (_data) => {
+ (AppInjector.get(AlertService) as AlertService).success(
+ 'Attempt successfully deleted.',
+ 6000,
+ );
+ },
+ error: (message) => {
+ (AppInjector.get(AlertService) as AlertService).error(message, 6000);
+ },
+ });
+ }
+}
diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html
new file mode 100644
index 000000000..f39a9580b
--- /dev/null
+++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html
@@ -0,0 +1,45 @@
+
Extra attempt request
+
+
+ Please explain why you require an extra attempt for this knowledge check, the teaching team will
+ assess the request shortly.
+
+
+
+ Reason
+
+ {{ extensionData.controls.extensionReason.value.length }} / {{ reasonMaxLength }}
+ @if (extensionData.controls.extensionReason.hasError('required')) {
+ You must enter a reason
+ }
+ @if (extensionData.controls.extensionReason.hasError('minlength')) {
+ The reason must be at least {{ reasonMinLength }} characters long
+ }
+ @if (extensionData.controls.extensionReason.hasError('maxlength')) {
+ The reason must be less than {{ reasonMaxLength }} characters long
+ }
+
+
+ Upload the corresponding SCORM 2004 test (e.g. Numbas)
+
+
+
+
+
+
+
+
+
+
+ 8
+
+
-
+
Optional settings
Apply other options
-
-
- Options
-
-
-
+
+
+
+ Options
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html
new file mode 100644
index 000000000..fb02d119a
--- /dev/null
+++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html
@@ -0,0 +1,64 @@
+
+
+ Enable test for task
+
+
+ @if (taskDefinition.scormEnabled) {
+
+ @if (taskDefinition.hasScormData) {
+
+
+
+
+
+ }
+
+
+
+
+ Allow students to review completed test attempt
+
+
+ Allow file upload regardless of test pass status
+
+
+
+ Limit to this number of attempts - 0 is unlimited
+
+
+
+
+
+
+ Enable incremental time delays between test attempts
+
+ If enabled, first 2 attempts can be completed immediately. Subsequently, a time delay will
+ be added, increasing by 2 hours every attempt made.
+