From ec7524aaae040794de76521363c2d885a0425db0 Mon Sep 17 00:00:00 2001 From: Bahnschrift Date: Fri, 31 May 2024 12:56:24 +1000 Subject: [PATCH 01/84] fix: marking shortcuts no longer conflict with common browser shortcuts --- .../staff-task-list.component.ts | 12 ++++----- .../states/tasks/inbox/inbox.component.ts | 26 +++++++++++++++---- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 95b498af2..5e427de56 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -123,26 +123,26 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } } ngOnDestroy(): void { - this.hotkeys.removeShortcuts('meta.shift.arrowdown'); - this.hotkeys.removeShortcuts('meta.shift.arrowup'); + this.hotkeys.removeShortcuts('alt.shift.arrowdown'); + this.hotkeys.removeShortcuts('alt.shift.arrowup'); } ngOnInit(): void { const registeredHotkeys = this.hotkeys.getHotkeys().map((hotkey) => hotkey.keys); - if (!registeredHotkeys.includes('meta.shift.arrowdown')) { + if (!registeredHotkeys.includes('alt.shift..arrowdown')) { this.hotkeys .addShortcut({ - keys: 'meta.shift.arrowdown', + keys: 'alt.shift.arrowdown', description: 'Select next task', }) .subscribe(() => this.nextTask()); } - if (!registeredHotkeys.includes('meta.shift.arrowup')) { + if (!registeredHotkeys.includes('alt.shift.arrowup')) { this.hotkeys .addShortcut({ - keys: 'meta.shift.arrowup', + keys: 'alt.shift.arrowup', description: 'Select previous task', }) .subscribe(() => this.previousTask()); diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index 79b4cfe35..ff7865da7 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -19,6 +19,7 @@ import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-ta import {HotkeysService, HotkeysHelpComponent} from '@ngneat/hotkeys'; import {MatDialog} from '@angular/material/dialog'; import {UserService} from 'src/app/api/services/user.service'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @Component({ selector: 'f-inbox', @@ -58,6 +59,7 @@ export class InboxComponent implements OnInit, AfterViewInit { private router: UIRouter, public dialog: MatDialog, private userService: UserService, + private constants: DoubtfireConstants, ) { this.selectedTask.currentPdfUrl$.subscribe((url) => { this.visiblePdfUrl = url; @@ -76,7 +78,7 @@ export class InboxComponent implements OnInit, AfterViewInit { const ref = this.dialog.open(HotkeysHelpComponent, { // width: '250px', }); - ref.componentInstance.title = 'Formatif Marking Shortcuts'; + ref.componentInstance.title = `${this.constants.ExternalName.value} Marking Shortcuts`; ref.componentInstance.dismiss.subscribe(() => ref.close()); }); } @@ -85,18 +87,32 @@ export class InboxComponent implements OnInit, AfterViewInit { ngOnInit(): void { this.hotkeys .addShortcut({ - keys: 'control.c', - description: 'Mark selected task as complete', + keys: 'alt.shift.r', + description: 'Mark selected task as redo', }) - .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('complete')); + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('redo')); this.hotkeys .addShortcut({ - keys: 'control.f', + keys: 'alt.shift.f', description: 'Mark selected task as fix', }) .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); + this.hotkeys + .addShortcut({ + keys: 'alt.shift.c', + description: 'Mark selected task as complete', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('complete')); + + this.hotkeys + .addShortcut({ + keys: 'alt.shift.d', + description: 'Mark selected task as discuss', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('discuss')); + this.dragMoveAudited$ = this.dragMove$.pipe( withLatestFrom(this.inboxStartSize$), auditTime(30), From 8eba913ec4462e1c252a8a698b4b6de67c4ec25f Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Sun, 10 Sep 2023 12:21:51 +1000 Subject: [PATCH 02/84] feat: add new Numbas Feature Added new Numbas Service to the frontend as part of Integration Changed by: Daniel Maddern --- src/app/api/services/numbas.service.ts | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/app/api/services/numbas.service.ts diff --git a/src/app/api/services/numbas.service.ts b/src/app/api/services/numbas.service.ts new file mode 100644 index 000000000..a0dc76564 --- /dev/null +++ b/src/app/api/services/numbas.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map, retry } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class NumbasService { + private readonly API_URL = 'http://localhost:3000/api/numbas_api'; + + constructor(private http: HttpClient) {} + + fetchResource(unitId: string, taskId: string, resourcePath: string): Observable { + const resourceUrl = `${this.API_URL}/${unitId}/${taskId}/${resourcePath}`; + const resourceMimeType = this.getMimeType(resourcePath); + + return this.http.get(resourceUrl, { responseType: 'blob' }).pipe( + retry(3), // Retrying up to 3 times before failing + map((blob) => new Blob([blob], { type: resourceMimeType })), + catchError((error: HttpErrorResponse) => { + console.error('Error fetching Numbas resource:', error); + return throwError('Error fetching Numbas resource.'); + }) + ); + } + + getMimeType(resourcePath: string): string { + const extension = resourcePath.split('.').pop()?.toLowerCase(); + const mimeTypeMap: { [key: string]: string } = { + 'html': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml' + }; + + return mimeTypeMap[extension || ''] || 'text/plain'; + } + uploadTest(unitId: string, taskId: string, file: File): Observable { + const uploadUrl = `${this.API_URL}/uploadNumbasTest`; + const formData = new FormData(); + + formData.append('file', file); + formData.append('unit_code', unitId); + formData.append('task_definition_id', taskId); + + const httpOptions = { + headers: new HttpHeaders({ + // You might need to set some headers here depending on your backend requirements + 'Accept': 'application/json' + }) + }; + + return this.http.post(uploadUrl, formData, httpOptions).pipe( + retry(3), + catchError((error: HttpErrorResponse) => { + console.error('Error uploading Numbas test:', error); + return throwError('Error uploading Numbas test.'); + }) + ); + } +} From 6f1ac4e4c1a78115c426838bce350bce286e44f0 Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Mon, 11 Sep 2023 17:11:47 +1000 Subject: [PATCH 03/84] feat: add new Numbas Feature adjusted lint on edit-profile-component.spec.ts Changed by: Daniel Maddern --- src/app/account/edit-profile/edit-profile.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/account/edit-profile/edit-profile.component.spec.ts b/src/app/account/edit-profile/edit-profile.component.spec.ts index 189015c5a..e15f8e582 100644 --- a/src/app/account/edit-profile/edit-profile.component.spec.ts +++ b/src/app/account/edit-profile/edit-profile.component.spec.ts @@ -7,7 +7,7 @@ describe('EditProfileComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [EditProfileComponent] + declarations: [EditProfileComponent], }).compileComponents(); fixture = TestBed.createComponent(EditProfileComponent); From cee13b727b35f804fc16070885f0e57c422c9982 Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Tue, 12 Sep 2023 08:54:37 +1000 Subject: [PATCH 04/84] feat: numbas-test-numbas-service Added numbas service and numbas service test daniel --- src/app/api/services/numbas.service.ts | 29 ++++++++- .../api/services/spec/numbas.service.spec.ts | 65 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/app/api/services/spec/numbas.service.spec.ts diff --git a/src/app/api/services/numbas.service.ts b/src/app/api/services/numbas.service.ts index a0dc76564..663c778f0 100644 --- a/src/app/api/services/numbas.service.ts +++ b/src/app/api/services/numbas.service.ts @@ -2,21 +2,30 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError, map, retry } from 'rxjs/operators'; +import API_URL from 'src/app/config/constants/apiURL'; @Injectable({ providedIn: 'root' }) export class NumbasService { - private readonly API_URL = 'http://localhost:3000/api/numbas_api'; + private readonly API_URL = `${API_URL}/numbas_api`; constructor(private http: HttpClient) {} + /** + * Fetches a specified resource for a given unit and task. + * + * @param unitId - The ID of the unit + * @param taskId - The ID of the task + * @param resourcePath - Path to the desired resource + * @returns An Observable with the Blob of the fetched resource + */ fetchResource(unitId: string, taskId: string, resourcePath: string): Observable { const resourceUrl = `${this.API_URL}/${unitId}/${taskId}/${resourcePath}`; const resourceMimeType = this.getMimeType(resourcePath); return this.http.get(resourceUrl, { responseType: 'blob' }).pipe( - retry(3), // Retrying up to 3 times before failing + retry(3), map((blob) => new Blob([blob], { type: resourceMimeType })), catchError((error: HttpErrorResponse) => { console.error('Error fetching Numbas resource:', error); @@ -25,6 +34,12 @@ export class NumbasService { ); } + /** + * Determines the MIME type of a resource based on its extension. + * + * @param resourcePath - Path of the resource + * @returns MIME type string corresponding to the resource's extension + */ getMimeType(resourcePath: string): string { const extension = resourcePath.split('.').pop()?.toLowerCase(); const mimeTypeMap: { [key: string]: string } = { @@ -40,6 +55,15 @@ export class NumbasService { return mimeTypeMap[extension || ''] || 'text/plain'; } + + /** + * Uploads a Numbas test file for a given unit and task. + * + * @param unitId - The ID of the unit + * @param taskId - The ID of the task + * @param file - File object representing the Numbas test to be uploaded + * @returns An Observable with the response from the server + */ uploadTest(unitId: string, taskId: string, file: File): Observable { const uploadUrl = `${this.API_URL}/uploadNumbasTest`; const formData = new FormData(); @@ -50,7 +74,6 @@ export class NumbasService { const httpOptions = { headers: new HttpHeaders({ - // You might need to set some headers here depending on your backend requirements 'Accept': 'application/json' }) }; diff --git a/src/app/api/services/spec/numbas.service.spec.ts b/src/app/api/services/spec/numbas.service.spec.ts new file mode 100644 index 000000000..7ef153047 --- /dev/null +++ b/src/app/api/services/spec/numbas.service.spec.ts @@ -0,0 +1,65 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { NumbasService } from '../numbas.service'; +import { HttpRequest } from '@angular/common/http'; + +describe('NumbasService', () => { + let numbasService: NumbasService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [NumbasService], + }); + + numbasService = TestBed.inject(NumbasService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should fetch resource as expected', fakeAsync(() => { + const dummyBlob = new Blob(['dummy blob'], { type: 'text/html' }); + + const unitId = 'sampleUnitId'; + const taskId = 'sampleTaskId'; + const resourcePath = 'sampleResource.html'; + + numbasService.fetchResource(unitId, taskId, resourcePath).subscribe((blob) => { + expect(blob.size).toBe(dummyBlob.size); + expect(blob.type).toBe(dummyBlob.type); + }); + + const req = httpMock.expectOne(`http://localhost:3000/api/numbas_api/${unitId}/${taskId}/${resourcePath}`); + expect(req.request.method).toBe('GET'); + + req.flush(dummyBlob); + + tick(); + })); + + it('should upload test as expected', fakeAsync(() => { + const dummyResponse = { success: true, message: 'File uploaded successfully' }; + + const unitId = 'sampleUnitId'; + const taskId = 'sampleTaskId'; + const file = new File(['dummy content'], 'sample.txt', { type: 'text/plain' }); + + numbasService.uploadTest(unitId, taskId, file).subscribe((response) => { + expect(response).toEqual(dummyResponse); + }); + + const req = httpMock.expectOne(`http://localhost:3000/api/numbas_api/uploadNumbasTest`); + expect(req.request.method).toBe('POST'); + + req.flush(dummyResponse); + + tick(); + })); + +}); + + From 8d3e3fd362a408e49fb1539f474e9e171830f5eb Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Tue, 12 Sep 2023 11:20:15 +1000 Subject: [PATCH 05/84] test: add numbas service test file added a spec test file for numbas service daniel --- src/app/api/services/spec/numbas.service.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/services/spec/numbas.service.spec.ts b/src/app/api/services/spec/numbas.service.spec.ts index 7ef153047..a29b13e14 100644 --- a/src/app/api/services/spec/numbas.service.spec.ts +++ b/src/app/api/services/spec/numbas.service.spec.ts @@ -3,6 +3,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/ import { NumbasService } from '../numbas.service'; import { HttpRequest } from '@angular/common/http'; + describe('NumbasService', () => { let numbasService: NumbasService; let httpMock: HttpTestingController; From 471d34486f2582e95aac84469187f087277cc079 Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Thu, 14 Sep 2023 09:29:45 +1000 Subject: [PATCH 06/84] feat: added numbas-lms service code added the lms service code and functionality Added by Daniel --- src/app/api/services/numbas-lms.service.ts | 248 +++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 src/app/api/services/numbas-lms.service.ts diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts new file mode 100644 index 000000000..268c15848 --- /dev/null +++ b/src/app/api/services/numbas-lms.service.ts @@ -0,0 +1,248 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; +import { TaskService } from './task.service'; +import { UserService } from './user.service'; +import API_URL from 'src/app/config/constants/apiURL'; + +declare let pipwerks: any; + +@Injectable({ + providedIn: 'root' +}) +export class NumbasLmsService { + + private readonly apiBaseUrl = `${API_URL}/savetests`;; + + private defaultValues: { [key: string]: string } = { + 'cmi.completion_status': 'not attempted', + 'cmi.entry': 'ab-initio', + 'numbas.user_role': 'learner', + 'numbas.duration_extension.units': 'seconds', + 'cmi.mode': 'normal', + 'cmi.undefinedlearner_response': '1', + 'cmi.undefinedresult' : '0' + + }; + + private testId: number = 0; + private taskId: number; + private learnerId: string; + initializationComplete$ = new BehaviorSubject(false); + + private scormErrors: { [key: string]: string } = { + "0": "No error", + "101": "General exception", + }; + + dataStore: { [key: string]: any } = this.getDefaultDataStore(); + + constructor( + private http: HttpClient, + private taskService: TaskService, + private userService: UserService +) { + pipwerks.SCORM.version = "2004"; + console.log(`SCORM version is set to: ${pipwerks.SCORM.version}`); + this.learnerId = this.userService.currentUser.studentId; + } + + getDefaultDataStore() { + // Use spread operator to merge defaultValues into the dataStore + return { + ...this.defaultValues, + pass_status: false, + completed: false, + }; + } + + Initialize(mode: 'attempt' | 'review' = 'attempt'): string { + console.log('Initialize() function called'); + const examName = 'test Exam Name 1'; + let xhr = new XMLHttpRequest(); + if (mode === 'review') { + this.SetValue('cmi.mode', 'review'); + + xhr.open("GET", `${this.apiBaseUrl}/completed-latest`, false); + xhr.send(); + console.log(xhr.responseText); + + if (xhr.status !== 200) { + console.error('Error fetching latest completed test result:', xhr.statusText); + return 'false'; + } + + try { + const completedTest = JSON.parse(xhr.responseText); + const parsedExamData = JSON.parse(completedTest.data.exam_data || '{}'); + + // Set entire suspendData string to cmi.suspend_data + this.SetValue('cmi.suspend_data', JSON.stringify(parsedExamData)); + + // Use SetValue to set parsedExamData values to dataStore + Object.keys(parsedExamData).forEach(key => { + this.SetValue(key, parsedExamData[key]); + }); + + this.SetValue('cmi.entry', 'RO'); + this.SetValue('cmi.mode', 'review'); + + console.log('Latest completed test data:', completedTest); + return 'true'; + + } catch (error) { + console.error('Error:', error); + return 'false'; + } + } + + xhr.open("GET", `${this.apiBaseUrl}/latest`, false); + xhr.send(); + console.log(xhr.responseText); + + if (xhr.status !== 200) { + console.error('Error fetching latest test result:', xhr.statusText); + return 'false'; + } + + let latestTest; + try { + latestTest = JSON.parse(xhr.responseText); + console.log('Latest test result:', latestTest); + this.testId = latestTest.data.id; + + if (latestTest.data['cmi_entry'] === 'ab-initio') { + console.log("starting new test"); + this.SetValue('cmi.learner_id', this.learnerId); + this.dataStore['name'] = examName; + this.dataStore['attempt_number'] = latestTest.data['attempt_number']; + console.log(this.dataStore); + } else if (latestTest.data['cmi_entry'] === 'resume') { + console.log("resuming test"); + const parsedExamData = JSON.parse(latestTest.data.exam_data || '{}'); + + this.dataStore = JSON.parse(JSON.stringify(parsedExamData)); + + console.log(this.dataStore); + } + + this.initializationComplete$.next(true); + + console.log("finished initlizing"); + return 'true'; + } catch (error) { + console.error('Error:', error); + return 'false'; + } +} + + + + isTestCompleted(): boolean { + return this.dataStore?.['completed'] || false; + } + + private resetDataStore() { + this.dataStore = this.getDefaultDataStore(); + } + + Terminate(): string { + console.log('Terminate Called'); + const examResult = this.dataStore["cmi.score.raw"]; + const status = this.GetValue("cmi.completion_status"); + this.dataStore['completed'] = true; + const currentAttemptNumber = this.dataStore['attempt_number'] || 0; + const ExamName = this.dataStore['name']; + this.SetValue('cmi.entry', 'RO'); + const cmientry = this.GetValue('cmi.entry'); + const data = { + task_id: this.taskId, + name: ExamName, + attempt_number: currentAttemptNumber, + pass_status: status === 'passed', + exam_data: JSON.stringify(this.dataStore), + completed: true, + exam_result: examResult, + cmi_entry: cmientry + }; + + const xhr = new XMLHttpRequest(); + if (this.testId) { + xhr.open("PUT", `${this.apiBaseUrl}/${this.testId}`, false); + } else { + xhr.open("POST", this.apiBaseUrl, false); + } + xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + xhr.send(JSON.stringify(data)); + + if (xhr.status !== 200) { + console.error('Error sending test data:', xhr.statusText); + return 'false'; + } + this.resetDataStore(); + return 'true'; + } + + GetValue(element: string): string { + return this.dataStore[element] || ''; + } + + SetValue(element: string, value: any): string { + if (element.startsWith('cmi.')) { + this.dataStore[element] = value; + } + return 'true'; + } +//function to save the state of the exam. +Commit(): string { + if (!this.initializationComplete$.getValue()) { + console.warn('Initialization not complete. Cannot commit.'); + return 'false'; + } + + // Set cmi.entry to 'resume' before committing dataStore + this.dataStore['cmi.entry'] = 'resume'; + if (!this.isTestCompleted()) { + this.dataStore['cmi.exit'] = 'suspend'; + } + console.log("Committing dataStore:", this.dataStore); + + // Directly stringify the dataStore + const jsonData = JSON.stringify(this.dataStore); + + // Use XHR to send the request + const xhr = new XMLHttpRequest(); + xhr.open('PUT', `${this.apiBaseUrl}/${this.testId}/suspend`, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 400) { + console.log('Suspend data saved successfully.'); + } else { + console.error('Error saving suspend data:', xhr.responseText); + } + }; + + xhr.onerror = () => { + console.error('Request failed.'); + }; + + xhr.send(jsonData); + return 'true'; +} + + // Placeholder methods for SCORM error handling + GetLastError(): string { + //console.log('Get Last Error called'); + return "0"; + } + + GetErrorString(errorCode: string): string { + return ''; + } + + GetDiagnostic(errorCode: string): string { + //console.log('Get Diagnoistic called'); + return ''; + } +} From 5d9d2c185fcf65f01a5a2630338b58324e1c49b8 Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Thu, 14 Sep 2023 09:35:31 +1000 Subject: [PATCH 07/84] test: added numbas-lms spec test added the spec test basic version for numbas-lms service Added by Daniel --- .../services/spec/numbas-lms.service.spec.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/app/api/services/spec/numbas-lms.service.spec.ts diff --git a/src/app/api/services/spec/numbas-lms.service.spec.ts b/src/app/api/services/spec/numbas-lms.service.spec.ts new file mode 100644 index 000000000..f456d5a89 --- /dev/null +++ b/src/app/api/services/spec/numbas-lms.service.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { NumbasLmsService } from '../numbas-lms.service'; +import { TaskService } from '../task.service'; +import { UserService } from '../user.service'; +import { of } from 'rxjs'; + +describe('NumbasLmsService', () => { + let service: NumbasLmsService; + let httpTestingController: HttpTestingController; + let mockUserService: Partial; + let mockTaskService: Partial; + + const mockUserData = { + currentUser: { studentId: '12345' } + }; + + beforeEach(() => { + mockUserService = { + currentUser: mockUserData.currentUser + }; + + mockTaskService = { + // you can add mocked methods if needed for the TaskService + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + NumbasLmsService, + { provide: UserService, useValue: mockUserService }, + { provide: TaskService, useValue: mockTaskService } + ] + }); + + service = TestBed.inject(NumbasLmsService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize with default values', () => { + expect(service.GetValue('cmi.completion_status')).toBe('not attempted'); + expect(service.GetValue('cmi.entry')).toBe('ab-initio'); + }); + + describe('Initialize function', () => { + + it('should handle review mode and get latest completed test result', () => { + const mockResponse = { + data: { + exam_data: JSON.stringify({ someData: 'value' }) + } + }; + + service.Initialize('review'); + const req = httpTestingController.expectOne(`${service['apiBaseUrl']}/completed-latest`); + expect(req.request.method).toEqual('GET'); + req.flush(mockResponse); + + expect(service.GetValue('cmi.suspend_data')).toEqual(JSON.stringify({ someData: 'value' })); + }); + + it('should handle attempt mode and get latest test result', () => { + const mockResponse = { + data: { + id: 1, + cmi_entry: 'ab-initio', + attempt_number: 2 + } + }; + + service.Initialize('attempt'); + const req = httpTestingController.expectOne(`${service['apiBaseUrl']}/latest`); + expect(req.request.method).toEqual('GET'); + req.flush(mockResponse); + + expect(service.GetValue('cmi.learner_id')).toBe('12345'); + }); + }); + +}); From 316abc775eeba6ca846c7c742bc88bec61d05a5e Mon Sep 17 00:00:00 2001 From: maddernd <87599686+maddernd@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:14:48 +1000 Subject: [PATCH 08/84] fix: adjusted edit profile accidental change removed the addtional comma added into this component daniel --- src/app/account/edit-profile/edit-profile.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/account/edit-profile/edit-profile.component.spec.ts b/src/app/account/edit-profile/edit-profile.component.spec.ts index e15f8e582..189015c5a 100644 --- a/src/app/account/edit-profile/edit-profile.component.spec.ts +++ b/src/app/account/edit-profile/edit-profile.component.spec.ts @@ -7,7 +7,7 @@ describe('EditProfileComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [EditProfileComponent], + declarations: [EditProfileComponent] }).compileComponents(); fixture = TestBed.createComponent(EditProfileComponent); From edbd536e73ace8b6b4cced1481c809fd36524dce Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:38:23 +1100 Subject: [PATCH 09/84] feat: add Numbas test upload section and reorder editor sections --- .../task-definition-editor.component.html | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index 5bbf1e64e..bd2c7ec65 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -104,6 +104,24 @@

+
+

Upload Numbas test

+

Upload the corresponding Numbas test

+
+ +
+
+ + +
+
+
+ 7 +
+
+

Task assessment automation @@ -123,7 +141,7 @@

- 7 + 8

From 4ecaee8ad1c0caed9a5d848066a173000989f79b Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:39:46 +1100 Subject: [PATCH 10/84] feat: add Numbas upload component and related functions to task-definition model --- src/app/api/models/task-definition.ts | 19 +++++ src/app/doubtfire-angular.module.ts | 2 + .../task-definition-numbas.component.html | 18 +++++ .../task-definition-numbas.component.scss | 0 .../task-definition-numbas.component.ts | 69 +++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html create mode 100644 src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.scss create mode 100644 src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 1b49a2e85..b5c4f5155 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -31,6 +31,7 @@ export class TaskDefinition extends Entity { groupSet: GroupSet = null; hasTaskSheet: boolean; hasTaskResources: boolean; + hasNumbasTest: boolean; hasTaskAssessmentResources: boolean; isGraded: boolean; maxQualityPts: number; @@ -152,6 +153,13 @@ export class TaskDefinition extends Entity { }`; } + public getNumbasTestUrl(asAttachment: boolean = false) { + const constants = AppInjector.get(DoubtfireConstants); + return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/numbas_test.json${ + asAttachment ? '?as_attachment=true' : '' + }`; + } + public get targetGradeText(): string { return Grade.GRADES[this.targetGrade]; } @@ -176,6 +184,12 @@ export class TaskDefinition extends Entity { }/task_resources`; } + public get numbasTestUploadUrl(): string { + return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ + this.id + }/numbas_test`; + } + public get taskAssessmentResourcesUploadUrl(): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ this.id @@ -198,6 +212,11 @@ export class TaskDefinition extends Entity { return httpClient.delete(this.taskResourcesUploadUrl).pipe(tap(() => (this.hasTaskResources = false))); } + public deleteNumbasTest(): Observable { + const httpClient = AppInjector.get(HttpClient); + return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasNumbasTest = false))); + } + public deleteTaskAssessmentResources(): Observable { const httpClient = AppInjector.get(HttpClient); return httpClient diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 928474598..a50478b80 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -203,6 +203,7 @@ import {TaskDefinitionUploadComponent} from './units/states/edit/directives/unit import {TaskDefinitionOptionsComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component'; import {TaskDefinitionResourcesComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component'; import {TaskDefinitionOverseerComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component'; +import {TaskDefinitionNumbasComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component'; import {UnitAnalyticsComponent} from './units/states/analytics/unit-analytics-route.component'; import {FileDropComponent} from './common/file-drop/file-drop.component'; import {UnitTaskEditorComponent} from './units/states/edit/directives/unit-tasks-editor/unit-task-editor.component'; @@ -262,6 +263,7 @@ import {GradeService} from './common/services/grade.service'; TaskDefinitionOptionsComponent, TaskDefinitionResourcesComponent, TaskDefinitionOverseerComponent, + TaskDefinitionNumbasComponent, UnitAnalyticsComponent, StudentTutorialSelectComponent, StudentCampusSelectComponent, diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html new file mode 100644 index 000000000..70367a462 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html @@ -0,0 +1,18 @@ +
+ + @if (taskDefinition.hasNumbasTest) { +
+ + +
+ } +
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts new file mode 100644 index 000000000..596f2933c --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts @@ -0,0 +1,69 @@ +import { Component, Inject, Input } from '@angular/core'; +import { alertService } from 'src/app/ajs-upgraded-providers'; +import { TaskDefinition } from 'src/app/api/models/task-definition'; +import { Unit } from 'src/app/api/models/unit'; +import { TaskDefinitionService } from 'src/app/api/services/task-definition.service'; +import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; + +@Component({ + selector: 'f-task-definition-numbas', + templateUrl: 'task-definition-numbas.component.html', + styleUrls: ['task-definition-numbas.component.scss'], +}) +export class TaskDefinitionNumbasComponent { + @Input() taskDefinition: TaskDefinition; + + constructor( + private fileDownloaderService: FileDownloaderService, + @Inject(alertService) private alerts: any, + private taskDefinitionService: TaskDefinitionService + ) {} + + public get unit(): Unit { + return this.taskDefinition?.unit; + } + + public downloadNumbasTest() { + this.fileDownloaderService.downloadFile( + this.taskDefinition.getNumbasTestUrl(true), + this.taskDefinition.name + '-Numbas.zip', + ); + } + + public removeNumbasTest() { + this.taskDefinition.deleteNumbasTest().subscribe({ + next: () => this.alerts.add('success', 'Deleted Numbas test', 2000), + error: (message) => this.alerts.add('danger', message, 6000), + }); + } + + public uploadNumbasTest(files: FileList) { + const validFiles = Array.from(files as ArrayLike).filter((f) => f.type === 'application/zip'); + if (validFiles.length > 0) { + const file = validFiles[0]; + // Temporary until Numbas backend is fixed: save uploaded file to local Downloads folder + this.saveZipFile(file); + this.taskDefinition.hasNumbasTest = true; + // this.taskDefinitionService.uploadNumbasTest(this.taskDefinition, file).subscribe({ + // next: () => this.alerts.add('success', 'Uploaded Numbas test', 2000), + // error: (message) => this.alerts.add('danger', message, 6000), + // }); + } else { + this.alerts.add('danger', 'Please drop a ZIP to upload for this task', 6000); + } + } + + private saveZipFile(zipData) { + const blob = new Blob([zipData], {type: 'application/zip'}); + + // Create an anchor element and set its href to the blob URL + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'numbas.zip'; + + // Append the link to the document, trigger the download, then remove the link + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +} From 7e52ad5e5759291d7d61805070ec482eb49c90be Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Thu, 7 Mar 2024 16:01:00 +1100 Subject: [PATCH 11/84] feat: insert Numbas test rules options in the task editor --- src/app/api/models/task-definition.ts | 9 ++- .../task-definition-numbas.component.html | 58 ++++++++++++++----- .../task-definition-numbas.component.ts | 2 +- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index b5c4f5155..9d19d5d1b 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -31,7 +31,12 @@ export class TaskDefinition extends Entity { groupSet: GroupSet = null; hasTaskSheet: boolean; hasTaskResources: boolean; - hasNumbasTest: boolean; + hasEnabledNumbasTest: boolean; + hasUploadedNumbasTest: boolean; + hasUnlimitedRetriesForNumbas: boolean; + hasTimeDelayForNumbas: boolean; + isNumbasRestrictedTo1Attempt: boolean; + numbasTimeDelay: string = 'no delay'; hasTaskAssessmentResources: boolean; isGraded: boolean; maxQualityPts: number; @@ -214,7 +219,7 @@ export class TaskDefinition extends Entity { public deleteNumbasTest(): Observable { const httpClient = AppInjector.get(HttpClient); - return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasNumbasTest = false))); + return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasUploadedNumbasTest = false))); } public deleteTaskAssessmentResources(): Observable { diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html index 70367a462..bd657d847 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html @@ -1,18 +1,44 @@ -
- - @if (taskDefinition.hasNumbasTest) { -
- - -
+
+ + Enable Numbas Test + + +
+ + @if (taskDefinition.hasUploadedNumbasTest) { +
+ + +
+ } +
+ +
+ Select test rules: + Unlimited retries + Time delay + Restrict to 1 attempt +
+ + @if (taskDefinition.hasTimeDelayForNumbas) { + + Time delay + + No delay + 30 min + 2 hours + 1 day + See tutor + + }
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts index 596f2933c..b36218bd1 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts @@ -43,7 +43,7 @@ export class TaskDefinitionNumbasComponent { const file = validFiles[0]; // Temporary until Numbas backend is fixed: save uploaded file to local Downloads folder this.saveZipFile(file); - this.taskDefinition.hasNumbasTest = true; + this.taskDefinition.hasUploadedNumbasTest = true; // this.taskDefinitionService.uploadNumbasTest(this.taskDefinition, file).subscribe({ // next: () => this.alerts.add('success', 'Uploaded Numbas test', 2000), // error: (message) => this.alerts.add('danger', message, 6000), From 2c7dab555fc9a226b980a8dae96f5738bf517b1b Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:46:53 +1100 Subject: [PATCH 12/84] feat: implement numbas test data upload in task definition service renamed api endpoint to reduce confusion between components --- src/app/api/models/task-definition.ts | 4 +-- .../api/services/task-definition.service.ts | 6 +++++ .../task-definition-numbas.component.ts | 26 ++++--------------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 9d19d5d1b..e575b4067 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -160,7 +160,7 @@ export class TaskDefinition extends Entity { public getNumbasTestUrl(asAttachment: boolean = false) { const constants = AppInjector.get(DoubtfireConstants); - return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/numbas_test.json${ + return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/numbas_data.json${ asAttachment ? '?as_attachment=true' : '' }`; } @@ -192,7 +192,7 @@ export class TaskDefinition extends Entity { public get numbasTestUploadUrl(): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ this.id - }/numbas_test`; + }/numbas_data`; } public get taskAssessmentResourcesUploadUrl(): string { diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index 13a1dd279..f9f14fb10 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -128,4 +128,10 @@ export class TaskDefinitionService extends CachedEntityService { formData.append('file', file); return AppInjector.get(HttpClient).post(taskDefinition.taskAssessmentResourcesUploadUrl, formData); } + + public uploadNumbasData(taskDefinition: TaskDefinition, file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return AppInjector.get(HttpClient).post(taskDefinition.numbasTestUploadUrl, formData); + } } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts index b36218bd1..3c18b3174 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts @@ -41,29 +41,13 @@ export class TaskDefinitionNumbasComponent { const validFiles = Array.from(files as ArrayLike).filter((f) => f.type === 'application/zip'); if (validFiles.length > 0) { const file = validFiles[0]; - // Temporary until Numbas backend is fixed: save uploaded file to local Downloads folder - this.saveZipFile(file); + this.taskDefinitionService.uploadNumbasData(this.taskDefinition, file).subscribe({ + next: () => this.alerts.add('success', 'Uploaded Numbas test data', 2000), + error: (message) => this.alerts.add('danger', message, 6000), + }); this.taskDefinition.hasUploadedNumbasTest = true; - // this.taskDefinitionService.uploadNumbasTest(this.taskDefinition, file).subscribe({ - // next: () => this.alerts.add('success', 'Uploaded Numbas test', 2000), - // error: (message) => this.alerts.add('danger', message, 6000), - // }); } else { - this.alerts.add('danger', 'Please drop a ZIP to upload for this task', 6000); + this.alerts.add('danger', 'Please drop a zip file to upload Numbas test data for this task', 6000); } } - - private saveZipFile(zipData) { - const blob = new Blob([zipData], {type: 'application/zip'}); - - // Create an anchor element and set its href to the blob URL - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = 'numbas.zip'; - - // Append the link to the document, trigger the download, then remove the link - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } } From ff28e4802b86dc22be339cc196ef82ade67934f7 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Fri, 22 Mar 2024 13:31:21 +1100 Subject: [PATCH 13/84] feat: add Numbas config options to task def service keys --- src/app/api/services/task-definition.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index f9f14fb10..903d3c73f 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -93,6 +93,12 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskSheet', 'hasTaskResources', 'hasTaskAssessmentResources', + 'hasEnabledNumbasTest', + 'hasUploadedNumbasTest', + 'hasUnlimitedRetriesForNumbas', + 'hasTimeDelayForNumbas', + 'isNumbasRestrictedTo1Attempt', + 'numbasTimeDelay', 'isGraded', 'maxQualityPts', 'overseerImageId', @@ -103,7 +109,8 @@ export class TaskDefinitionService extends CachedEntityService { 'id', 'hasTaskSheet', 'hasTaskResources', - 'hasTaskAssessmentResources' + 'hasTaskAssessmentResources', + 'hasUploadedNumbasTest' ); } From 821feb93a8a40d095ef4f50adedbfd9216278fa9 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:29:06 +1100 Subject: [PATCH 14/84] fix: show delete and download buttons in editor when Numbas test exists --- src/app/api/models/task-definition.ts | 4 ++-- src/app/api/services/task-definition.service.ts | 4 ++-- .../task-definition-numbas.component.html | 2 +- .../task-definition-numbas.component.ts | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index e575b4067..81b2793d1 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -32,7 +32,7 @@ export class TaskDefinition extends Entity { hasTaskSheet: boolean; hasTaskResources: boolean; hasEnabledNumbasTest: boolean; - hasUploadedNumbasTest: boolean; + hasNumbasData: boolean; hasUnlimitedRetriesForNumbas: boolean; hasTimeDelayForNumbas: boolean; isNumbasRestrictedTo1Attempt: boolean; @@ -219,7 +219,7 @@ export class TaskDefinition extends Entity { public deleteNumbasTest(): Observable { const httpClient = AppInjector.get(HttpClient); - return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasUploadedNumbasTest = false))); + return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasNumbasData = false))); } public deleteTaskAssessmentResources(): Observable { diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index 903d3c73f..a64649b9d 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -94,7 +94,7 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskResources', 'hasTaskAssessmentResources', 'hasEnabledNumbasTest', - 'hasUploadedNumbasTest', + 'hasNumbasData', 'hasUnlimitedRetriesForNumbas', 'hasTimeDelayForNumbas', 'isNumbasRestrictedTo1Attempt', @@ -110,7 +110,7 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskSheet', 'hasTaskResources', 'hasTaskAssessmentResources', - 'hasUploadedNumbasTest' + 'hasNumbasData' ); } diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html index bd657d847..38beb3ab7 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html @@ -10,7 +10,7 @@ accept="application/zip" [desiredFileName]="'Numbas zip'" /> - @if (taskDefinition.hasUploadedNumbasTest) { + @if (taskDefinition.hasNumbasData) {
-
- Select test rules: - Unlimited retries - Time delay - Restrict to 1 attempt -
+ @if (taskDefinition.hasEnabledNumbasTest) { +
+ Select test rules: + Unlimited retries + Time delay + Restrict to 1 attempt +
- @if (taskDefinition.hasTimeDelayForNumbas) { - - Time delay - - No delay - 30 min - 2 hours - 1 day - See tutor - - + @if (taskDefinition.hasTimeDelayForNumbas) { + + Time delay + + No delay + 30 min + 2 hours + 1 day + See tutor + + + } }
From 6e96584e7f0efba22b6f3b64ad1bcbfcbf31ee0f Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 23 Mar 2024 00:03:29 +1100 Subject: [PATCH 17/84] refactor: replace Numbas config checkboxes with input fields --- src/app/api/models/task-definition.ts | 4 +--- .../api/services/task-definition.service.ts | 4 +--- .../task-definition-numbas.component.html | 24 +++++++++++-------- .../task-definition-numbas.component.ts | 3 +++ 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 81b2793d1..7d012fb2b 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -33,10 +33,8 @@ export class TaskDefinition extends Entity { hasTaskResources: boolean; hasEnabledNumbasTest: boolean; hasNumbasData: boolean; - hasUnlimitedRetriesForNumbas: boolean; - hasTimeDelayForNumbas: boolean; - isNumbasRestrictedTo1Attempt: boolean; numbasTimeDelay: string = 'no delay'; + numbasAttemptLimit: number = 0; hasTaskAssessmentResources: boolean; isGraded: boolean; maxQualityPts: number; diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index a64649b9d..3724b29b1 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -95,10 +95,8 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskAssessmentResources', 'hasEnabledNumbasTest', 'hasNumbasData', - 'hasUnlimitedRetriesForNumbas', - 'hasTimeDelayForNumbas', - 'isNumbasRestrictedTo1Attempt', 'numbasTimeDelay', + 'numbasAttemptLimit', 'isGraded', 'maxQualityPts', 'overseerImageId', diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html index 3786ea90a..712f01547 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html @@ -23,15 +23,8 @@
@if (taskDefinition.hasEnabledNumbasTest) { -
- Select test rules: - Unlimited retries - Time delay - Restrict to 1 attempt -
- - @if (taskDefinition.hasTimeDelayForNumbas) { - +
+ Time delay No delay @@ -41,6 +34,17 @@ See tutor - } + + Attempt Limit + + +
} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts index 878ec2531..6a26501ba 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts @@ -1,4 +1,5 @@ import { Component, Inject, Input } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; import { alertService } from 'src/app/ajs-upgraded-providers'; import { TaskDefinition } from 'src/app/api/models/task-definition'; import { Unit } from 'src/app/api/models/unit'; @@ -19,6 +20,8 @@ export class TaskDefinitionNumbasComponent { private taskDefinitionService: TaskDefinitionService ) {} + public scoreControl = new FormControl('', [Validators.max(100), Validators.min(0)]); + public get unit(): Unit { return this.taskDefinition?.unit; } From 7c1734b137ac369b3b605b38749c845df38b9a78 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sun, 17 Dec 2023 21:28:21 +1100 Subject: [PATCH 18/84] feat: add numbas component --- .../numbas-component.component.html | 4 + .../numbas-component.component.scss | 0 .../numbas-component.component.spec.ts | 23 ++++++ .../numbas-component.component.ts | 77 +++++++++++++++++++ src/app/doubtfire-angular.module.ts | 6 ++ 5 files changed, 110 insertions(+) create mode 100644 src/app/common/numbas-component/numbas-component.component.html create mode 100644 src/app/common/numbas-component/numbas-component.component.scss create mode 100644 src/app/common/numbas-component/numbas-component.component.spec.ts create mode 100644 src/app/common/numbas-component/numbas-component.component.ts diff --git a/src/app/common/numbas-component/numbas-component.component.html b/src/app/common/numbas-component/numbas-component.component.html new file mode 100644 index 000000000..438dbc6d1 --- /dev/null +++ b/src/app/common/numbas-component/numbas-component.component.html @@ -0,0 +1,4 @@ +

Run Numbas Test

+ + + diff --git a/src/app/common/numbas-component/numbas-component.component.scss b/src/app/common/numbas-component/numbas-component.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/common/numbas-component/numbas-component.component.spec.ts b/src/app/common/numbas-component/numbas-component.component.spec.ts new file mode 100644 index 000000000..31dad5e30 --- /dev/null +++ b/src/app/common/numbas-component/numbas-component.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NumbasComponent } from './numbas-component.component'; + +describe('NumbasComponent', () => { + let component: NumbasComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ NumbasComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(NumbasComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts new file mode 100644 index 000000000..7a4fa96b9 --- /dev/null +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -0,0 +1,77 @@ +import { Component, OnInit } from '@angular/core'; +import { NumbasService } from 'src/app/api/services/numbas.service'; +import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; + +declare global { + interface Window { API_1484_11: any; } +} + +@Component({ + selector: 'numbas-component', + templateUrl: './numbas-component.component.html' +}) +export class NumbasComponent implements OnInit { + currentMode: 'attempt' | 'review' = 'attempt'; + constructor( + private numbasService: NumbasService, + private lmsService: NumbasLmsService + ) {} + + ngOnInit(): void { + this.interceptIframeRequests(); + + window.API_1484_11 = { + Initialize: () => this.lmsService.Initialize(this.currentMode), + Terminate: () => this.lmsService.Terminate(), + GetValue: (element: string) => this.lmsService.GetValue(element), + SetValue: (element: string, value: string) => this.lmsService.SetValue(element, value), + Commit: () => this.lmsService.Commit(), + GetLastError: () => this.lmsService.GetLastError(), + GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), + GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) + }; + } + + launchNumbasTest(mode: 'attempt' | 'review' = 'attempt'): void { + this.currentMode = mode; + const iframe = document.createElement('iframe'); + iframe.src = 'http://localhost:4201/api/numbas_api/index.html'; + iframe.style.width = '100%'; + iframe.style.height = '800px'; + document.body.appendChild(iframe); + } + setReviewMode(): void { + this.reviewTest(); + } + + removeNumbasTest(): void { + const iframe = document.getElementsByTagName('iframe')[0]; + iframe?.parentNode?.removeChild(iframe); + } + reviewTest(): void { + this.launchNumbasTest('review'); + } + + interceptIframeRequests(): void { + const originalOpen = XMLHttpRequest.prototype.open; + const numbasService = this.numbasService; + XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, async: boolean = true, username?: string | null, password?: string | null) { + if (typeof url === 'string' && url.startsWith('/api/numbas_api/')) { + const resourcePath = url.replace('/api/numbas_api/', ''); + this.abort(); + numbasService.fetchResource('1', '1', resourcePath).subscribe( + (resourceData) => { + if (this.onload) { + this.onload.call(this, resourceData); + } + }, + (error) => { + console.error('Error fetching Numbas resource:', error); + } + ); + } else { + originalOpen.call(this, method, url, async, username, password); + } + }; + } +} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index a50478b80..d15c23b23 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -225,6 +225,9 @@ import {FTaskSheetViewComponent} from './units/states/tasks/viewer/directives/f- import {TasksViewerComponent} from './units/states/tasks/tasks-viewer/tasks-viewer.component'; import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {GradeService} from './common/services/grade.service'; +import {NumbasComponent} from './common/numbas-component/numbas-component.component'; +import {NumbasService} from './api/services/numbas.service'; +import {NumbasLmsService} from './api/services/numbas-lms.service'; @NgModule({ // Components we declare @@ -327,6 +330,7 @@ import {GradeService} from './common/services/grade.service'; FUsersComponent, FTaskBadgeComponent, FUnitsComponent, + NumbasComponent, ], // Services we provide providers: [ @@ -398,6 +402,8 @@ import {GradeService} from './common/services/grade.service'; TasksForInboxSearchPipe, IsActiveUnitRole, CreateNewUnitModal, + NumbasService, + NumbasLmsService, provideLottieOptions({ player: () => player, }), From e61295c50006dbc89cfc0639bb3c68a09a3cda9d Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 23 Mar 2024 00:15:06 +1100 Subject: [PATCH 19/84] feat: add Numbas test section on ready for feedback --- .../numbas-component.component.html | 1 - .../numbas-component.component.ts | 2 +- .../upload-submission-modal.coffee | 2 +- .../upload-submission-modal.tpl.html | 18 ++++++++++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app/common/numbas-component/numbas-component.component.html b/src/app/common/numbas-component/numbas-component.component.html index 438dbc6d1..0f0f2eb14 100644 --- a/src/app/common/numbas-component/numbas-component.component.html +++ b/src/app/common/numbas-component/numbas-component.component.html @@ -1,4 +1,3 @@ -

Run Numbas Test

diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 7a4fa96b9..2e5cff929 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -7,7 +7,7 @@ declare global { } @Component({ - selector: 'numbas-component', + selector: 'f-numbas-component', templateUrl: './numbas-component.component.html' }) export class NumbasComponent implements OnInit { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index b9c9ebd2b..9fea8fbad 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -100,7 +100,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) # States functionality states = { # All possible states - all: ['group', 'files', 'alignment', 'comments', 'uploading'] + all: ['group', 'numbas', 'files', 'alignment', 'comments', 'uploading'] # Only states which are shown (populated in initialise) shown: [] # The currently active state (set in initialise) diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index 9caab7897..edd0fda10 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -43,6 +43,24 @@

+
+
+
+

+ Attempt Numbas Test +

+ + Complete the Numbas test first to proceed to upload evidence of your task completion. + +
+
+ +
+
+
From 56e1a5de44ee275f9544b77909b7472f1020c2c3 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 23 Mar 2024 17:36:52 +1100 Subject: [PATCH 20/84] fix: show previously configured Numbas attempt limit --- .../task-definition-numbas.component.html | 6 +++--- .../task-definition-numbas.component.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html index 712f01547..7ff44a604 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html @@ -35,14 +35,14 @@ - Attempt Limit + Attempt limit
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts index 6a26501ba..658845343 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts @@ -20,7 +20,7 @@ export class TaskDefinitionNumbasComponent { private taskDefinitionService: TaskDefinitionService ) {} - public scoreControl = new FormControl('', [Validators.max(100), Validators.min(0)]); + public attemptLimitControl = new FormControl('', [Validators.max(100), Validators.min(0)]); public get unit(): Unit { return this.taskDefinition?.unit; From 0afa7197293c90a154c1716db91ecadae3677d53 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sun, 24 Mar 2024 13:41:29 +1100 Subject: [PATCH 21/84] feat: change Numbas time delay config to enable incremental delays --- src/app/api/models/task-definition.ts | 2 +- src/app/api/services/task-definition.service.ts | 2 +- .../task-definition-numbas.component.html | 13 +++---------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 7d012fb2b..a669b2fe3 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -33,7 +33,7 @@ export class TaskDefinition extends Entity { hasTaskResources: boolean; hasEnabledNumbasTest: boolean; hasNumbasData: boolean; - numbasTimeDelay: string = 'no delay'; + hasNumbasTimeDelay: boolean; numbasAttemptLimit: number = 0; hasTaskAssessmentResources: boolean; isGraded: boolean; diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index 3724b29b1..6047af054 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -95,7 +95,7 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskAssessmentResources', 'hasEnabledNumbasTest', 'hasNumbasData', - 'numbasTimeDelay', + 'hasNumbasTimeDelay', 'numbasAttemptLimit', 'isGraded', 'maxQualityPts', diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html index 7ff44a604..e0021065a 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html @@ -24,16 +24,9 @@ @if (taskDefinition.hasEnabledNumbasTest) {
- - Time delay - - No delay - 30 min - 2 hours - 1 day - See tutor - - + + Enable incremental time delays between test attempts + Attempt limit Date: Wed, 27 Mar 2024 23:48:29 +1100 Subject: [PATCH 22/84] feat: show launch button on ready for feedback if Numbas test is enabled for the task --- .../common/numbas-component/numbas-component.component.html | 6 +++--- src/app/doubtfire-angularjs.module.ts | 4 ++++ .../upload-submission-modal/upload-submission-modal.coffee | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/common/numbas-component/numbas-component.component.html b/src/app/common/numbas-component/numbas-component.component.html index 0f0f2eb14..dba53ea11 100644 --- a/src/app/common/numbas-component/numbas-component.component.html +++ b/src/app/common/numbas-component/numbas-component.component.html @@ -1,3 +1,3 @@ - - - + diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 868e38121..317094dc2 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -225,6 +225,8 @@ import {FUnitsComponent} from './admin/states/f-units/f-units.component'; import {MarkedPipe} from './common/pipes/marked.pipe'; import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; +import {NumbasComponent} from './common/numbas-component/numbas-component.component'; + export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', 'doubtfire.sessions', @@ -440,6 +442,8 @@ DoubtfireAngularJSModule.directive( ); DoubtfireAngularJSModule.directive('fUnits', downgradeComponent({component: FUnitsComponent})); +DoubtfireAngularJSModule.directive('fNumbasComponent', downgradeComponent({component: NumbasComponent})); + // Global configuration DoubtfireAngularJSModule.directive( 'taskCommentsViewer', diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index 9fea8fbad..485843f78 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -128,6 +128,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) removed.push('group') if !isRFF || !task.isGroupTask() removed.push('alignment') if !isRFF || !task.unit.ilos.length > 0 removed.push('comments') if isTestSubmission + removed.push('numbas') if !task.definition.hasEnabledNumbasTest removed # Initialises the states initialise: -> From 097df7960d34a8c905ee495b8c6c8b8843e43b36 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:02:02 +1100 Subject: [PATCH 23/84] feat: add Numbas test attempt model --- src/app/api/models/test-attempt.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/app/api/models/test-attempt.ts diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts new file mode 100644 index 000000000..77e4af172 --- /dev/null +++ b/src/app/api/models/test-attempt.ts @@ -0,0 +1,14 @@ +import { Entity } from "ngx-entity-service"; + +export class TestAttempt extends Entity { + id: number; + name: string; + attemptNumber: number; + passStatus: boolean; + examData: string; + completed: boolean; + cmiEntry: string; + examResult: string; + attemptedAt: Date; + associatedTaskId: number; +} From d47b8edc745660801984346c4fa67d6bb371f4cb Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Thu, 28 Mar 2024 13:02:56 +1100 Subject: [PATCH 24/84] fix: integrate Numbas services well with the existing system --- src/app/api/services/numbas-lms.service.ts | 144 +++++++++--------- src/app/api/services/numbas.service.ts | 10 +- .../numbas-component.component.ts | 49 +++--- .../upload-submission-modal.tpl.html | 2 +- 4 files changed, 110 insertions(+), 95 deletions(-) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index 268c15848..caf3ad8d6 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Input } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { TaskService } from './task.service'; import { UserService } from './user.service'; import API_URL from 'src/app/config/constants/apiURL'; +import { Task } from '../models/task'; declare let pipwerks: any; @@ -12,7 +13,7 @@ declare let pipwerks: any; }) export class NumbasLmsService { - private readonly apiBaseUrl = `${API_URL}/savetests`;; + private readonly apiBaseUrl = `${API_URL}/test_attempts`; private defaultValues: { [key: string]: string } = { 'cmi.completion_status': 'not attempted', @@ -22,7 +23,6 @@ export class NumbasLmsService { 'cmi.mode': 'normal', 'cmi.undefinedlearner_response': '1', 'cmi.undefinedresult' : '0' - }; private testId: number = 0; @@ -41,12 +41,16 @@ export class NumbasLmsService { private http: HttpClient, private taskService: TaskService, private userService: UserService -) { + ) { pipwerks.SCORM.version = "2004"; console.log(`SCORM version is set to: ${pipwerks.SCORM.version}`); this.learnerId = this.userService.currentUser.studentId; } + setTask(task: Task) { + this.taskId = task.id; + } + getDefaultDataStore() { // Use spread operator to merge defaultValues into the dataStore return { @@ -63,40 +67,39 @@ export class NumbasLmsService { if (mode === 'review') { this.SetValue('cmi.mode', 'review'); - xhr.open("GET", `${this.apiBaseUrl}/completed-latest`, false); + xhr.open("GET", `${this.apiBaseUrl}/completed-latest?task_id=${this.taskId}`, false); xhr.send(); console.log(xhr.responseText); if (xhr.status !== 200) { - console.error('Error fetching latest completed test result:', xhr.statusText); - return 'false'; + console.error('Error fetching latest completed test result:', xhr.statusText); + return 'false'; } try { - const completedTest = JSON.parse(xhr.responseText); - const parsedExamData = JSON.parse(completedTest.data.exam_data || '{}'); + const completedTest = JSON.parse(xhr.responseText); + const parsedExamData = JSON.parse(completedTest.data.exam_data || '{}'); - // Set entire suspendData string to cmi.suspend_data - this.SetValue('cmi.suspend_data', JSON.stringify(parsedExamData)); + // Set entire suspendData string to cmi.suspend_data + this.SetValue('cmi.suspend_data', JSON.stringify(parsedExamData)); - // Use SetValue to set parsedExamData values to dataStore - Object.keys(parsedExamData).forEach(key => { - this.SetValue(key, parsedExamData[key]); - }); + // Use SetValue to set parsedExamData values to dataStore + Object.keys(parsedExamData).forEach(key => { + this.SetValue(key, parsedExamData[key]); + }); - this.SetValue('cmi.entry', 'RO'); - this.SetValue('cmi.mode', 'review'); - - console.log('Latest completed test data:', completedTest); - return 'true'; + this.SetValue('cmi.entry', 'RO'); + this.SetValue('cmi.mode', 'review'); + console.log('Latest completed test data:', completedTest); + return 'true'; } catch (error) { - console.error('Error:', error); - return 'false'; + console.error('Error:', error); + return 'false'; } } - xhr.open("GET", `${this.apiBaseUrl}/latest`, false); + xhr.open("GET", `${this.apiBaseUrl}/latest?task_id=${this.taskId}`, false); xhr.send(); console.log(xhr.responseText); @@ -107,36 +110,34 @@ export class NumbasLmsService { let latestTest; try { - latestTest = JSON.parse(xhr.responseText); - console.log('Latest test result:', latestTest); - this.testId = latestTest.data.id; - - if (latestTest.data['cmi_entry'] === 'ab-initio') { - console.log("starting new test"); - this.SetValue('cmi.learner_id', this.learnerId); - this.dataStore['name'] = examName; - this.dataStore['attempt_number'] = latestTest.data['attempt_number']; - console.log(this.dataStore); + latestTest = JSON.parse(xhr.responseText); + console.log('Latest test result:', latestTest); + this.testId = latestTest.data.id; + + if (latestTest.data['cmi_entry'] === 'ab-initio') { + console.log("starting new test"); + this.SetValue('cmi.learner_id', this.learnerId); + this.dataStore['name'] = examName; + this.dataStore['attempt_number'] = latestTest.data['attempt_number']; + console.log(this.dataStore); } else if (latestTest.data['cmi_entry'] === 'resume') { - console.log("resuming test"); - const parsedExamData = JSON.parse(latestTest.data.exam_data || '{}'); + console.log("resuming test"); + const parsedExamData = JSON.parse(latestTest.data.exam_data || '{}'); - this.dataStore = JSON.parse(JSON.stringify(parsedExamData)); + this.dataStore = JSON.parse(JSON.stringify(parsedExamData)); - console.log(this.dataStore); - } + console.log(this.dataStore); + } - this.initializationComplete$.next(true); + this.initializationComplete$.next(true); - console.log("finished initlizing"); - return 'true'; + console.log("finished initlizing"); + return 'true'; } catch (error) { - console.error('Error:', error); - return 'false'; + console.error('Error:', error); + return 'false'; } -} - - + } isTestCompleted(): boolean { return this.dataStore?.['completed'] || false; @@ -193,43 +194,44 @@ export class NumbasLmsService { } return 'true'; } -//function to save the state of the exam. -Commit(): string { - if (!this.initializationComplete$.getValue()) { + + //function to save the state of the exam. + Commit(): string { + if (!this.initializationComplete$.getValue()) { console.warn('Initialization not complete. Cannot commit.'); return 'false'; - } + } - // Set cmi.entry to 'resume' before committing dataStore - this.dataStore['cmi.entry'] = 'resume'; - if (!this.isTestCompleted()) { - this.dataStore['cmi.exit'] = 'suspend'; - } - console.log("Committing dataStore:", this.dataStore); + // Set cmi.entry to 'resume' before committing dataStore + this.dataStore['cmi.entry'] = 'resume'; + if (!this.isTestCompleted()) { + this.dataStore['cmi.exit'] = 'suspend'; + } + console.log("Committing dataStore:", this.dataStore); - // Directly stringify the dataStore - const jsonData = JSON.stringify(this.dataStore); + // Directly stringify the dataStore + const jsonData = JSON.stringify(this.dataStore); - // Use XHR to send the request - const xhr = new XMLHttpRequest(); - xhr.open('PUT', `${this.apiBaseUrl}/${this.testId}/suspend`, true); - xhr.setRequestHeader('Content-Type', 'application/json'); + // Use XHR to send the request + const xhr = new XMLHttpRequest(); + xhr.open('PUT', `${this.apiBaseUrl}/${this.testId}/exam_data`, true); + xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = () => { + xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 400) { - console.log('Suspend data saved successfully.'); + console.log('Suspend data saved successfully.'); } else { - console.error('Error saving suspend data:', xhr.responseText); + console.error('Error saving suspend data:', xhr.responseText); } - }; + }; - xhr.onerror = () => { + xhr.onerror = () => { console.error('Request failed.'); - }; + }; - xhr.send(jsonData); - return 'true'; -} + xhr.send(jsonData); + return 'true'; + } // Placeholder methods for SCORM error handling GetLastError(): string { diff --git a/src/app/api/services/numbas.service.ts b/src/app/api/services/numbas.service.ts index 0fb41fdcc..d9d4a2a8b 100644 --- a/src/app/api/services/numbas.service.ts +++ b/src/app/api/services/numbas.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError, map, retry } from 'rxjs/operators'; import API_URL from 'src/app/config/constants/apiURL'; @@ -8,20 +8,18 @@ import API_URL from 'src/app/config/constants/apiURL'; providedIn: 'root' }) export class NumbasService { - private readonly API_URL = `${API_URL}/numbas_api`; - constructor(private http: HttpClient) {} /** * Fetches a specified resource for a given unit and task. * * @param unitId - The ID of the unit - * @param taskId - The ID of the task + * @param taskDefId - The ID of the task definition * @param resourcePath - Path to the desired resource * @returns An Observable with the Blob of the fetched resource */ - fetchResource(unitId: string, taskId: string, resourcePath: string): Observable { - const resourceUrl = `${this.API_URL}/${unitId}/${taskId}/${resourcePath}`; + fetchResource(unitId: number, taskDefId: number, resourcePath: string): Observable { + const resourceUrl = `${API_URL}/units/${unitId}/task_definitions/${taskDefId}/numbas_data/${resourcePath}`; const resourceMimeType = this.getMimeType(resourcePath); return this.http.get(resourceUrl, { responseType: 'blob' }).pipe( diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 2e5cff929..2c16411ea 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -1,6 +1,8 @@ -import { Component, OnInit } from '@angular/core'; -import { NumbasService } from 'src/app/api/services/numbas.service'; +import { Component, Input, OnChanges } from '@angular/core'; +import { Task } from 'src/app/api/models/task'; +import { Unit } from 'src/app/api/models/unit'; import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; +import { NumbasService } from 'src/app/api/services/numbas.service'; declare global { interface Window { API_1484_11: any; } @@ -10,36 +12,46 @@ declare global { selector: 'f-numbas-component', templateUrl: './numbas-component.component.html' }) -export class NumbasComponent implements OnInit { +export class NumbasComponent implements OnChanges { + @Input() task: Task; + unit: Unit; + currentMode: 'attempt' | 'review' = 'attempt'; + constructor( private numbasService: NumbasService, private lmsService: NumbasLmsService ) {} - ngOnInit(): void { - this.interceptIframeRequests(); + ngOnChanges(): void { + if (this.task) { + this.lmsService.setTask(this.task); + this.unit = this.task.unit; - window.API_1484_11 = { - Initialize: () => this.lmsService.Initialize(this.currentMode), - Terminate: () => this.lmsService.Terminate(), - GetValue: (element: string) => this.lmsService.GetValue(element), - SetValue: (element: string, value: string) => this.lmsService.SetValue(element, value), - Commit: () => this.lmsService.Commit(), - GetLastError: () => this.lmsService.GetLastError(), - GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), - GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) - }; + this.interceptIframeRequests(); + + window.API_1484_11 = { + Initialize: () => this.lmsService.Initialize(this.currentMode), + Terminate: () => this.lmsService.Terminate(), + GetValue: (element: string) => this.lmsService.GetValue(element), + SetValue: (element: string, value: string) => this.lmsService.SetValue(element, value), + Commit: () => this.lmsService.Commit(), + GetLastError: () => this.lmsService.GetLastError(), + GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), + GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) + }; + } } launchNumbasTest(mode: 'attempt' | 'review' = 'attempt'): void { this.currentMode = mode; const iframe = document.createElement('iframe'); - iframe.src = 'http://localhost:4201/api/numbas_api/index.html'; + iframe.src = 'http://localhost:4200/api/numbas_api/index.html'; iframe.style.width = '100%'; iframe.style.height = '800px'; document.body.appendChild(iframe); } + setReviewMode(): void { this.reviewTest(); } @@ -48,6 +60,7 @@ export class NumbasComponent implements OnInit { const iframe = document.getElementsByTagName('iframe')[0]; iframe?.parentNode?.removeChild(iframe); } + reviewTest(): void { this.launchNumbasTest('review'); } @@ -55,11 +68,13 @@ export class NumbasComponent implements OnInit { interceptIframeRequests(): void { const originalOpen = XMLHttpRequest.prototype.open; const numbasService = this.numbasService; + const unitId = this.unit.id; + const taskDefId = this.task.definition.id; XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, async: boolean = true, username?: string | null, password?: string | null) { if (typeof url === 'string' && url.startsWith('/api/numbas_api/')) { const resourcePath = url.replace('/api/numbas_api/', ''); this.abort(); - numbasService.fetchResource('1', '1', resourcePath).subscribe( + numbasService.fetchResource(unitId, taskDefId, resourcePath).subscribe( (resourceData) => { if (this.onload) { this.onload.call(this, resourceData); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index edd0fda10..d6636d914 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -57,7 +57,7 @@

- +
From c9c2fbe10db156512946355f3cabfe54461f0eba Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 9 Apr 2024 10:44:09 +1000 Subject: [PATCH 25/84] fix: show Numbas button component and modify iframe request --- src/app/api/services/numbas-lms.service.ts | 4 -- .../numbas-component.component.ts | 69 +++++++------------ .../upload-submission-modal.tpl.html | 6 +- 3 files changed, 31 insertions(+), 48 deletions(-) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index caf3ad8d6..de5a25bcc 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -6,8 +6,6 @@ import { UserService } from './user.service'; import API_URL from 'src/app/config/constants/apiURL'; import { Task } from '../models/task'; -declare let pipwerks: any; - @Injectable({ providedIn: 'root' }) @@ -42,8 +40,6 @@ export class NumbasLmsService { private taskService: TaskService, private userService: UserService ) { - pipwerks.SCORM.version = "2004"; - console.log(`SCORM version is set to: ${pipwerks.SCORM.version}`); this.learnerId = this.userService.currentUser.studentId; } diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 2c16411ea..1193adcc1 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -1,5 +1,6 @@ -import { Component, Input, OnChanges } from '@angular/core'; +import { Component, Input, OnInit, Renderer2 } from '@angular/core'; import { Task } from 'src/app/api/models/task'; +import { TaskDefinition } from 'src/app/api/models/task-definition'; import { Unit } from 'src/app/api/models/unit'; import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; import { NumbasService } from 'src/app/api/services/numbas.service'; @@ -12,69 +13,51 @@ declare global { selector: 'f-numbas-component', templateUrl: './numbas-component.component.html' }) -export class NumbasComponent implements OnChanges { +export class NumbasComponent implements OnInit { @Input() task: Task; - unit: Unit; + @Input() unit: Unit; + @Input() taskDef: TaskDefinition; currentMode: 'attempt' | 'review' = 'attempt'; constructor( private numbasService: NumbasService, - private lmsService: NumbasLmsService - ) {} + private lmsService: NumbasLmsService, + private renderer: Renderer2 + ) { } - ngOnChanges(): void { - if (this.task) { - this.lmsService.setTask(this.task); - this.unit = this.task.unit; + ngOnInit(): void { + this.interceptIframeRequests(); - this.interceptIframeRequests(); - - window.API_1484_11 = { - Initialize: () => this.lmsService.Initialize(this.currentMode), - Terminate: () => this.lmsService.Terminate(), - GetValue: (element: string) => this.lmsService.GetValue(element), - SetValue: (element: string, value: string) => this.lmsService.SetValue(element, value), - Commit: () => this.lmsService.Commit(), - GetLastError: () => this.lmsService.GetLastError(), - GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), - GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) - }; - } + window.API_1484_11 = { + Initialize: () => this.lmsService.Initialize(this.currentMode), + Terminate: () => this.lmsService.Terminate(), + GetValue: (element: string) => this.lmsService.GetValue(element), + SetValue: (element: string, value: string) => this.lmsService.SetValue(element, value), + Commit: () => this.lmsService.Commit(), + GetLastError: () => this.lmsService.GetLastError(), + GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), + GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) + }; } launchNumbasTest(mode: 'attempt' | 'review' = 'attempt'): void { this.currentMode = mode; - const iframe = document.createElement('iframe'); - iframe.src = 'http://localhost:4200/api/numbas_api/index.html'; - iframe.style.width = '100%'; - iframe.style.height = '800px'; - document.body.appendChild(iframe); - } - - setReviewMode(): void { - this.reviewTest(); - } - - removeNumbasTest(): void { - const iframe = document.getElementsByTagName('iframe')[0]; - iframe?.parentNode?.removeChild(iframe); - } - - reviewTest(): void { - this.launchNumbasTest('review'); + const iframe = this.renderer.createElement('iframe'); + this.renderer.setAttribute(iframe, 'src', 'http://localhost:3000/api/numbas_api/units/1/task_definitions/1/numbas_data/index.html'); + this.renderer.setStyle(iframe, 'width', '100%'); + this.renderer.setStyle(iframe, 'height', '800px'); + this.renderer.appendChild(document.body, iframe); } interceptIframeRequests(): void { const originalOpen = XMLHttpRequest.prototype.open; const numbasService = this.numbasService; - const unitId = this.unit.id; - const taskDefId = this.task.definition.id; XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, async: boolean = true, username?: string | null, password?: string | null) { if (typeof url === 'string' && url.startsWith('/api/numbas_api/')) { const resourcePath = url.replace('/api/numbas_api/', ''); this.abort(); - numbasService.fetchResource(unitId, taskDefId, resourcePath).subscribe( + numbasService.fetchResource(1, 1, resourcePath).subscribe( (resourceData) => { if (this.onload) { this.onload.call(this, resourceData); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index d6636d914..cd207a363 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -57,7 +57,11 @@

- + +
From f53befd82b52bba24bc8c593a9266c11f9155d32 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 9 Apr 2024 12:23:02 +1000 Subject: [PATCH 26/84] fix: show Numbas iframe on top of other elements --- .../numbas-component.component.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 1193adcc1..d2b8bea62 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, Renderer2 } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { Task } from 'src/app/api/models/task'; import { TaskDefinition } from 'src/app/api/models/task-definition'; import { Unit } from 'src/app/api/models/unit'; @@ -23,13 +23,12 @@ export class NumbasComponent implements OnInit { constructor( private numbasService: NumbasService, private lmsService: NumbasLmsService, - private renderer: Renderer2 - ) { } + ) {} ngOnInit(): void { this.interceptIframeRequests(); - window.API_1484_11 = { + window.API_1484_11 = { Initialize: () => this.lmsService.Initialize(this.currentMode), Terminate: () => this.lmsService.Terminate(), GetValue: (element: string) => this.lmsService.GetValue(element), @@ -43,11 +42,20 @@ export class NumbasComponent implements OnInit { launchNumbasTest(mode: 'attempt' | 'review' = 'attempt'): void { this.currentMode = mode; - const iframe = this.renderer.createElement('iframe'); - this.renderer.setAttribute(iframe, 'src', 'http://localhost:3000/api/numbas_api/units/1/task_definitions/1/numbas_data/index.html'); - this.renderer.setStyle(iframe, 'width', '100%'); - this.renderer.setStyle(iframe, 'height', '800px'); - this.renderer.appendChild(document.body, iframe); + const iframe = document.createElement('iframe'); + iframe.src = 'http://example.org'; + iframe.style.position = 'fixed'; + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.zIndex = '9999'; // Set a high z-index value + + // Get the topmost element in the document + var topElement = document.documentElement.firstChild; + + // Replace the top element with the iframe + document.documentElement.replaceChild(iframe, topElement); } interceptIframeRequests(): void { From 3df59dcc58f021ef16d81938d1d05473818bc75e Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Tue, 9 Apr 2024 16:28:28 +1000 Subject: [PATCH 27/84] fix: update numbas api path --- src/app/api/services/numbas.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/services/numbas.service.ts b/src/app/api/services/numbas.service.ts index d9d4a2a8b..6f2f750bf 100644 --- a/src/app/api/services/numbas.service.ts +++ b/src/app/api/services/numbas.service.ts @@ -19,7 +19,7 @@ export class NumbasService { * @returns An Observable with the Blob of the fetched resource */ fetchResource(unitId: number, taskDefId: number, resourcePath: string): Observable { - const resourceUrl = `${API_URL}/units/${unitId}/task_definitions/${taskDefId}/numbas_data/${resourcePath}`; + const resourceUrl = `${API_URL}/numbas_api/${taskDefId}/numbas_data/${resourcePath}`; const resourceMimeType = this.getMimeType(resourcePath); return this.http.get(resourceUrl, { responseType: 'blob' }).pipe( From bee0a0bb1eb905c964caf7888419effe70553520 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:58:40 +1000 Subject: [PATCH 28/84] fix: show correct Numbas test from the task def with all assets loaded --- src/app/api/services/numbas.service.ts | 5 ++- .../numbas-component.component.ts | 33 ++++++++++--------- .../upload-submission-modal.tpl.html | 6 +--- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/app/api/services/numbas.service.ts b/src/app/api/services/numbas.service.ts index 6f2f750bf..f8812f1ac 100644 --- a/src/app/api/services/numbas.service.ts +++ b/src/app/api/services/numbas.service.ts @@ -13,13 +13,12 @@ export class NumbasService { /** * Fetches a specified resource for a given unit and task. * - * @param unitId - The ID of the unit * @param taskDefId - The ID of the task definition * @param resourcePath - Path to the desired resource * @returns An Observable with the Blob of the fetched resource */ - fetchResource(unitId: number, taskDefId: number, resourcePath: string): Observable { - const resourceUrl = `${API_URL}/numbas_api/${taskDefId}/numbas_data/${resourcePath}`; + fetchResource(taskDefId: number, resourcePath: string): Observable { + const resourceUrl = `${API_URL}/numbas_api/${taskDefId}/${resourcePath}`; const resourceMimeType = this.getMimeType(resourcePath); return this.http.get(resourceUrl, { responseType: 'blob' }).pipe( diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index d2b8bea62..529a64b9a 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -1,7 +1,5 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core'; import { Task } from 'src/app/api/models/task'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; import { NumbasService } from 'src/app/api/services/numbas.service'; @@ -11,12 +9,11 @@ declare global { @Component({ selector: 'f-numbas-component', - templateUrl: './numbas-component.component.html' + templateUrl: './numbas-component.component.html', + styleUrls: ['numbas-component.component.scss'], }) -export class NumbasComponent implements OnInit { +export class NumbasComponent implements OnInit, OnChanges { @Input() task: Task; - @Input() unit: Unit; - @Input() taskDef: TaskDefinition; currentMode: 'attempt' | 'review' = 'attempt'; @@ -40,32 +37,38 @@ export class NumbasComponent implements OnInit { }; } + ngOnChanges(changes: SimpleChanges): void { + if (changes.task) { + this.task = changes.task.currentValue; + this.lmsService.setTask(this.task); + } + } + launchNumbasTest(mode: 'attempt' | 'review' = 'attempt'): void { this.currentMode = mode; + const iframe = document.createElement('iframe'); - iframe.src = 'http://example.org'; + iframe.src = `http://localhost:3000/api/numbas_api/${this.task.taskDefId}/index.html`; + iframe.style.position = 'fixed'; iframe.style.top = '0'; iframe.style.left = '0'; iframe.style.width = '100%'; iframe.style.height = '100%'; - iframe.style.zIndex = '9999'; // Set a high z-index value - - // Get the topmost element in the document - var topElement = document.documentElement.firstChild; + iframe.style.zIndex = '9999'; - // Replace the top element with the iframe - document.documentElement.replaceChild(iframe, topElement); + document.body.appendChild(iframe); } interceptIframeRequests(): void { const originalOpen = XMLHttpRequest.prototype.open; const numbasService = this.numbasService; + const taskDefId = this.task.taskDefId; XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, async: boolean = true, username?: string | null, password?: string | null) { if (typeof url === 'string' && url.startsWith('/api/numbas_api/')) { const resourcePath = url.replace('/api/numbas_api/', ''); this.abort(); - numbasService.fetchResource(1, 1, resourcePath).subscribe( + numbasService.fetchResource(taskDefId, resourcePath).subscribe( (resourceData) => { if (this.onload) { this.onload.call(this, resourceData); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index cd207a363..0b99e7eb8 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -57,11 +57,7 @@

- - +
From 64b1bfb2918993e58a6659948d9621fc7d0b8ba4 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 13 Apr 2024 21:56:47 +1000 Subject: [PATCH 29/84] fix: use modal for Numbas and enable authentication --- src/app/ajs-upgraded-providers.ts | 7 ++ src/app/api/services/numbas.service.ts | 55 -------------- .../api/services/spec/numbas.service.spec.ts | 66 ---------------- .../numbas-component.component.html | 7 +- .../numbas-component.component.scss | 17 +++++ .../numbas-component.component.ts | 76 ++++++------------- .../numbas-modal.component.ts | 20 +++++ src/app/doubtfire-angular.module.ts | 4 +- src/app/doubtfire-angularjs.module.ts | 2 + .../upload-submission-modal.coffee | 5 +- .../upload-submission-modal.tpl.html | 6 +- 11 files changed, 83 insertions(+), 182 deletions(-) delete mode 100644 src/app/api/services/numbas.service.ts delete mode 100644 src/app/api/services/spec/numbas.service.spec.ts create mode 100644 src/app/common/numbas-component/numbas-modal.component.ts diff --git a/src/app/ajs-upgraded-providers.ts b/src/app/ajs-upgraded-providers.ts index 795869225..9d5b0f020 100644 --- a/src/app/ajs-upgraded-providers.ts +++ b/src/app/ajs-upgraded-providers.ts @@ -18,6 +18,7 @@ export const rootScope = new InjectionToken('$rootScope'); export const calendarModal = new InjectionToken('CalendarModal'); export const aboutDoubtfireModal = new InjectionToken('AboutDoubtfireModal'); export const plagiarismReportModal = new InjectionToken('PlagiarismReportModal'); +export const numbasModal = new InjectionToken('NumbasModal'); // Define a provider for the above injection token... // It will get the service from AngularJS via the factory @@ -116,3 +117,9 @@ export const UnitStudentEnrolmentModalProvider = { useFactory: (i) => i.get('UnitStudentEnrolmentModal'), deps: ['$injector'], }; + +export const numbasModalProvider = { + provide: numbasModal, + useFactory: (i) => i.get('NumbasModal'), + deps: ['$injector'], +}; \ No newline at end of file diff --git a/src/app/api/services/numbas.service.ts b/src/app/api/services/numbas.service.ts deleted file mode 100644 index f8812f1ac..000000000 --- a/src/app/api/services/numbas.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; -import { Observable, throwError } from 'rxjs'; -import { catchError, map, retry } from 'rxjs/operators'; -import API_URL from 'src/app/config/constants/apiURL'; - -@Injectable({ - providedIn: 'root' -}) -export class NumbasService { - constructor(private http: HttpClient) {} - - /** - * Fetches a specified resource for a given unit and task. - * - * @param taskDefId - The ID of the task definition - * @param resourcePath - Path to the desired resource - * @returns An Observable with the Blob of the fetched resource - */ - fetchResource(taskDefId: number, resourcePath: string): Observable { - const resourceUrl = `${API_URL}/numbas_api/${taskDefId}/${resourcePath}`; - const resourceMimeType = this.getMimeType(resourcePath); - - return this.http.get(resourceUrl, { responseType: 'blob' }).pipe( - retry(3), - map((blob) => new Blob([blob], { type: resourceMimeType })), - catchError((error: HttpErrorResponse) => { - console.error('Error fetching Numbas resource:', error); - return throwError('Error fetching Numbas resource.'); - }) - ); - } - - /** - * Determines the MIME type of a resource based on its extension. - * - * @param resourcePath - Path of the resource - * @returns MIME type string corresponding to the resource's extension - */ - getMimeType(resourcePath: string): string { - const extension = resourcePath.split('.').pop()?.toLowerCase(); - const mimeTypeMap: { [key: string]: string } = { - 'html': 'text/html', - 'css': 'text/css', - 'js': 'application/javascript', - 'json': 'application/json', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'gif': 'image/gif', - 'svg': 'image/svg+xml' - }; - - return mimeTypeMap[extension || ''] || 'text/plain'; - } -} diff --git a/src/app/api/services/spec/numbas.service.spec.ts b/src/app/api/services/spec/numbas.service.spec.ts deleted file mode 100644 index a29b13e14..000000000 --- a/src/app/api/services/spec/numbas.service.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { NumbasService } from '../numbas.service'; -import { HttpRequest } from '@angular/common/http'; - - -describe('NumbasService', () => { - let numbasService: NumbasService; - let httpMock: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [NumbasService], - }); - - numbasService = TestBed.inject(NumbasService); - httpMock = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should fetch resource as expected', fakeAsync(() => { - const dummyBlob = new Blob(['dummy blob'], { type: 'text/html' }); - - const unitId = 'sampleUnitId'; - const taskId = 'sampleTaskId'; - const resourcePath = 'sampleResource.html'; - - numbasService.fetchResource(unitId, taskId, resourcePath).subscribe((blob) => { - expect(blob.size).toBe(dummyBlob.size); - expect(blob.type).toBe(dummyBlob.type); - }); - - const req = httpMock.expectOne(`http://localhost:3000/api/numbas_api/${unitId}/${taskId}/${resourcePath}`); - expect(req.request.method).toBe('GET'); - - req.flush(dummyBlob); - - tick(); - })); - - it('should upload test as expected', fakeAsync(() => { - const dummyResponse = { success: true, message: 'File uploaded successfully' }; - - const unitId = 'sampleUnitId'; - const taskId = 'sampleTaskId'; - const file = new File(['dummy content'], 'sample.txt', { type: 'text/plain' }); - - numbasService.uploadTest(unitId, taskId, file).subscribe((response) => { - expect(response).toEqual(dummyResponse); - }); - - const req = httpMock.expectOne(`http://localhost:3000/api/numbas_api/uploadNumbasTest`); - expect(req.request.method).toBe('POST'); - - req.flush(dummyResponse); - - tick(); - })); - -}); - - diff --git a/src/app/common/numbas-component/numbas-component.component.html b/src/app/common/numbas-component/numbas-component.component.html index dba53ea11..d562f0223 100644 --- a/src/app/common/numbas-component/numbas-component.component.html +++ b/src/app/common/numbas-component/numbas-component.component.html @@ -1,3 +1,4 @@ - +
+ + +
\ No newline at end of file diff --git a/src/app/common/numbas-component/numbas-component.component.scss b/src/app/common/numbas-component/numbas-component.component.scss index e69de29bb..5e24cc5d9 100644 --- a/src/app/common/numbas-component/numbas-component.component.scss +++ b/src/app/common/numbas-component/numbas-component.component.scss @@ -0,0 +1,17 @@ +.mat-dialog-content { + position: relative; +} + +iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 95%; +} + +button { + position: absolute; + bottom: 20px; + right: 20px; +} \ No newline at end of file diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 529a64b9a..6c8369ab7 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -1,7 +1,11 @@ -import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, OnInit, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Task } from 'src/app/api/models/task'; import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; -import { NumbasService } from 'src/app/api/services/numbas.service'; +import { UserService } from 'src/app/api/services/user.service'; +import { AppInjector } from 'src/app/app-injector'; +import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; declare global { interface Window { API_1484_11: any; } @@ -10,20 +14,29 @@ declare global { @Component({ selector: 'f-numbas-component', templateUrl: './numbas-component.component.html', - styleUrls: ['numbas-component.component.scss'], + styleUrls: ['./numbas-component.component.scss'], }) -export class NumbasComponent implements OnInit, OnChanges { - @Input() task: Task; - +export class NumbasComponent implements OnInit { + task: Task; currentMode: 'attempt' | 'review' = 'attempt'; + iframeSrc: SafeResourceUrl; constructor( - private numbasService: NumbasService, + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { task: Task, mode: 'attempt' | 'review' }, private lmsService: NumbasLmsService, + private userService: UserService, + private sanitizer: DomSanitizer ) {} ngOnInit(): void { - this.interceptIframeRequests(); + this.task = this.data.task; + this.lmsService.setTask(this.task); + + this.currentMode = this.data.mode; + + const user = this.userService.currentUser; + this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(`${AppInjector.get(DoubtfireConstants).API_URL}/numbas_api/${this.task.taskDefId}/${user.authenticationToken}/${user.username}/index.html`); window.API_1484_11 = { Initialize: () => this.lmsService.Initialize(this.currentMode), @@ -37,50 +50,7 @@ export class NumbasComponent implements OnInit, OnChanges { }; } - ngOnChanges(changes: SimpleChanges): void { - if (changes.task) { - this.task = changes.task.currentValue; - this.lmsService.setTask(this.task); - } - } - - launchNumbasTest(mode: 'attempt' | 'review' = 'attempt'): void { - this.currentMode = mode; - - const iframe = document.createElement('iframe'); - iframe.src = `http://localhost:3000/api/numbas_api/${this.task.taskDefId}/index.html`; - - iframe.style.position = 'fixed'; - iframe.style.top = '0'; - iframe.style.left = '0'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.zIndex = '9999'; - - document.body.appendChild(iframe); - } - - interceptIframeRequests(): void { - const originalOpen = XMLHttpRequest.prototype.open; - const numbasService = this.numbasService; - const taskDefId = this.task.taskDefId; - XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, method: string, url: string | URL, async: boolean = true, username?: string | null, password?: string | null) { - if (typeof url === 'string' && url.startsWith('/api/numbas_api/')) { - const resourcePath = url.replace('/api/numbas_api/', ''); - this.abort(); - numbasService.fetchResource(taskDefId, resourcePath).subscribe( - (resourceData) => { - if (this.onload) { - this.onload.call(this, resourceData); - } - }, - (error) => { - console.error('Error fetching Numbas resource:', error); - } - ); - } else { - originalOpen.call(this, method, url, async, username, password); - } - }; + removeNumbasTest(): void { + this.dialogRef.close(); } } diff --git a/src/app/common/numbas-component/numbas-modal.component.ts b/src/app/common/numbas-component/numbas-modal.component.ts new file mode 100644 index 000000000..73b2122c1 --- /dev/null +++ b/src/app/common/numbas-component/numbas-modal.component.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { NumbasComponent } from './numbas-component.component'; +import { Task } from 'src/app/api/models/task'; + +@Injectable({ + providedIn: 'root', +}) +export class NumbasModal { + constructor(public dialog: MatDialog) { } + + public show(task: Task, mode: 'attempt' | 'review'): void { + let dialogRef: MatDialogRef; + + dialogRef = this.dialog.open(NumbasComponent, { + data: { task, mode }, + width: '95%', height: '90%' + }); + } +} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index d15c23b23..48f54027d 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -226,7 +226,7 @@ import {TasksViewerComponent} from './units/states/tasks/tasks-viewer/tasks-view import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {GradeService} from './common/services/grade.service'; import {NumbasComponent} from './common/numbas-component/numbas-component.component'; -import {NumbasService} from './api/services/numbas.service'; +import {NumbasModal} from './common/numbas-component/numbas-modal.component'; import {NumbasLmsService} from './api/services/numbas-lms.service'; @NgModule({ @@ -402,7 +402,7 @@ import {NumbasLmsService} from './api/services/numbas-lms.service'; TasksForInboxSearchPipe, IsActiveUnitRole, CreateNewUnitModal, - NumbasService, + NumbasModal, NumbasLmsService, provideLottieOptions({ player: () => player, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 317094dc2..b88fbeb29 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -226,6 +226,7 @@ import {MarkedPipe} from './common/pipes/marked.pipe'; import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; import {NumbasComponent} from './common/numbas-component/numbas-component.component'; +import {NumbasModal} from './common/numbas-component/numbas-modal.component'; export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', @@ -308,6 +309,7 @@ DoubtfireAngularJSModule.factory( downgradeInjectable(EditProfileDialogService), ); DoubtfireAngularJSModule.factory('CreateNewUnitModal', downgradeInjectable(CreateNewUnitModal)); +DoubtfireAngularJSModule.factory('NumbasModal', downgradeInjectable(NumbasModal)); // directive -> component DoubtfireAngularJSModule.directive( diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index 485843f78..68d742362 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -32,7 +32,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) UploadSubmissionModal ) -.controller('UploadSubmissionModalCtrl', ($scope, $rootScope, $timeout, $modalInstance, newTaskService, newProjectService, task, reuploadEvidence, outcomeService, PrivacyPolicy) -> +.controller('UploadSubmissionModalCtrl', ($scope, $rootScope, $timeout, $modalInstance, NumbasModal, newTaskService, newProjectService, task, reuploadEvidence, outcomeService, PrivacyPolicy) -> $scope.privacyPolicy = PrivacyPolicy # Expose task to scope $scope.task = task @@ -155,6 +155,9 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) previous: states.previous } + $scope.launchNumbasDialog = -> + NumbasModal.show $scope.task, 'attempt' + # Whether or not we should disable this button $scope.shouldDisableBtn = { next: -> diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index 0b99e7eb8..0527aadd4 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -52,12 +52,14 @@

Attempt Numbas Test

- + Complete the Numbas test first to proceed to upload evidence of your task completion.
- +
From bcaa8af150aaf68b5cf5fb07ad9cb72037212d5d Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Fri, 26 Apr 2024 22:10:46 +1000 Subject: [PATCH 30/84] fix: add accepted Numbas file types --- .../task-definition-numbas.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts index 658845343..f6687a4af 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts @@ -41,7 +41,9 @@ export class TaskDefinitionNumbasComponent { } public uploadNumbasTest(files: FileList) { - const validFiles = Array.from(files as ArrayLike).filter((f) => f.type === 'application/zip'); + console.log(Array.from(files).map(f => f.type)); + const validMimeTypes = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']; + const validFiles = Array.from(files as ArrayLike).filter(f => validMimeTypes.includes(f.type)); if (validFiles.length > 0) { const file = validFiles[0]; this.taskDefinitionService.uploadNumbasData(this.taskDefinition, file).subscribe({ From 5d0606c56eaeb929d5873cae7038ce729581512c Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:30:07 +1000 Subject: [PATCH 31/84] fix: initialise SCORM API wrapper before iframe loads --- .../numbas-component/numbas-component.component.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 6c8369ab7..488c0a633 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -3,7 +3,6 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Task } from 'src/app/api/models/task'; import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; -import { UserService } from 'src/app/api/services/user.service'; import { AppInjector } from 'src/app/app-injector'; import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; @@ -25,7 +24,6 @@ export class NumbasComponent implements OnInit { private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: { task: Task, mode: 'attempt' | 'review' }, private lmsService: NumbasLmsService, - private userService: UserService, private sanitizer: DomSanitizer ) {} @@ -33,11 +31,6 @@ export class NumbasComponent implements OnInit { this.task = this.data.task; this.lmsService.setTask(this.task); - this.currentMode = this.data.mode; - - const user = this.userService.currentUser; - this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(`${AppInjector.get(DoubtfireConstants).API_URL}/numbas_api/${this.task.taskDefId}/${user.authenticationToken}/${user.username}/index.html`); - window.API_1484_11 = { Initialize: () => this.lmsService.Initialize(this.currentMode), Terminate: () => this.lmsService.Terminate(), @@ -48,6 +41,10 @@ export class NumbasComponent implements OnInit { GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) }; + + this.currentMode = this.data.mode; + + this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(`${AppInjector.get(DoubtfireConstants).API_URL}/numbas_api/${this.task.taskDefId}/index.html`); } removeNumbasTest(): void { From 2810ce65d423a137600d621a98bf27dbc221921c Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:32:29 +1000 Subject: [PATCH 32/84] fix: retrieve test attempt data correctly --- src/app/api/services/numbas-lms.service.ts | 29 ++++++++-------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index de5a25bcc..a24c8d31e 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -1,7 +1,5 @@ -import { Injectable, Input } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { BehaviorSubject, Observable, throwError } from 'rxjs'; -import { TaskService } from './task.service'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; import { UserService } from './user.service'; import API_URL from 'src/app/config/constants/apiURL'; import { Task } from '../models/task'; @@ -10,7 +8,6 @@ import { Task } from '../models/task'; providedIn: 'root' }) export class NumbasLmsService { - private readonly apiBaseUrl = `${API_URL}/test_attempts`; private defaultValues: { [key: string]: string } = { @@ -35,11 +32,7 @@ export class NumbasLmsService { dataStore: { [key: string]: any } = this.getDefaultDataStore(); - constructor( - private http: HttpClient, - private taskService: TaskService, - private userService: UserService - ) { + constructor(private userService: UserService) { this.learnerId = this.userService.currentUser.studentId; } @@ -74,7 +67,7 @@ export class NumbasLmsService { try { const completedTest = JSON.parse(xhr.responseText); - const parsedExamData = JSON.parse(completedTest.data.exam_data || '{}'); + const parsedExamData = JSON.parse(completedTest.exam_data || '{}'); // Set entire suspendData string to cmi.suspend_data this.SetValue('cmi.suspend_data', JSON.stringify(parsedExamData)); @@ -108,17 +101,17 @@ export class NumbasLmsService { try { latestTest = JSON.parse(xhr.responseText); console.log('Latest test result:', latestTest); - this.testId = latestTest.data.id; + this.testId = latestTest.id; - if (latestTest.data['cmi_entry'] === 'ab-initio') { + if (latestTest['cmi_entry'] === 'ab-initio') { console.log("starting new test"); this.SetValue('cmi.learner_id', this.learnerId); this.dataStore['name'] = examName; - this.dataStore['attempt_number'] = latestTest.data['attempt_number']; + this.dataStore['attempt_number'] = latestTest['attempt_number']; console.log(this.dataStore); - } else if (latestTest.data['cmi_entry'] === 'resume') { + } else if (latestTest['cmi_entry'] === 'resume') { console.log("resuming test"); - const parsedExamData = JSON.parse(latestTest.data.exam_data || '{}'); + const parsedExamData = JSON.parse(latestTest.exam_data || '{}'); this.dataStore = JSON.parse(JSON.stringify(parsedExamData)); @@ -127,7 +120,7 @@ export class NumbasLmsService { this.initializationComplete$.next(true); - console.log("finished initlizing"); + console.log("finished initializing"); return 'true'; } catch (error) { console.error('Error:', error); @@ -153,7 +146,7 @@ export class NumbasLmsService { this.SetValue('cmi.entry', 'RO'); const cmientry = this.GetValue('cmi.entry'); const data = { - task_id: this.taskId, + id: this.taskId, name: ExamName, attempt_number: currentAttemptNumber, pass_status: status === 'passed', From e46214dd59d86014bb04d1c31f3a3bf21240e32b Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 30 Apr 2024 13:02:50 +1000 Subject: [PATCH 33/84] fix: send task id with numbas completed attempt data --- src/app/api/services/numbas-lms.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index a24c8d31e..40ce0942b 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -69,7 +69,7 @@ export class NumbasLmsService { const completedTest = JSON.parse(xhr.responseText); const parsedExamData = JSON.parse(completedTest.exam_data || '{}'); - // Set entire suspendData string to cmi.suspend_data + // Set entire parsedExamData string to cmi.suspend_data this.SetValue('cmi.suspend_data', JSON.stringify(parsedExamData)); // Use SetValue to set parsedExamData values to dataStore @@ -146,14 +146,14 @@ export class NumbasLmsService { this.SetValue('cmi.entry', 'RO'); const cmientry = this.GetValue('cmi.entry'); const data = { - id: this.taskId, name: ExamName, attempt_number: currentAttemptNumber, pass_status: status === 'passed', exam_data: JSON.stringify(this.dataStore), completed: true, exam_result: examResult, - cmi_entry: cmientry + cmi_entry: cmientry, + task_id: this.taskId }; const xhr = new XMLHttpRequest(); From 84646dcf7b28c65c8c28f046197d08d3325795be Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sun, 5 May 2024 16:33:19 +1000 Subject: [PATCH 34/84] refactor: modify numbas files to match PoC --- src/app/api/services/numbas-lms.service.ts | 38 +++++++++---------- .../services/spec/numbas-lms.service.spec.ts | 2 +- .../numbas-component.component.ts | 2 + 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index 40ce0942b..6479a6ed9 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -17,7 +17,7 @@ export class NumbasLmsService { 'numbas.duration_extension.units': 'seconds', 'cmi.mode': 'normal', 'cmi.undefinedlearner_response': '1', - 'cmi.undefinedresult' : '0' + 'cmi.undefinedresult': '0' }; private testId: number = 0; @@ -67,14 +67,14 @@ export class NumbasLmsService { try { const completedTest = JSON.parse(xhr.responseText); - const parsedExamData = JSON.parse(completedTest.exam_data || '{}'); + let parsedSuspendData = JSON.parse(completedTest.data.suspend_data || '{}'); - // Set entire parsedExamData string to cmi.suspend_data - this.SetValue('cmi.suspend_data', JSON.stringify(parsedExamData)); + // Set entire suspendData string to cmi.suspend_data + this.SetValue('cmi.suspend_data', JSON.stringify(parsedSuspendData)); - // Use SetValue to set parsedExamData values to dataStore - Object.keys(parsedExamData).forEach(key => { - this.SetValue(key, parsedExamData[key]); + // Use SetValue to set parsedSuspendData values to dataStore + Object.keys(parsedSuspendData).forEach(key => { + this.SetValue(key, parsedSuspendData[key]); }); this.SetValue('cmi.entry', 'RO'); @@ -101,19 +101,19 @@ export class NumbasLmsService { try { latestTest = JSON.parse(xhr.responseText); console.log('Latest test result:', latestTest); - this.testId = latestTest.id; + this.testId = latestTest.data.id; - if (latestTest['cmi_entry'] === 'ab-initio') { + if (latestTest.data['cmi_entry'] === 'ab-initio') { console.log("starting new test"); this.SetValue('cmi.learner_id', this.learnerId); this.dataStore['name'] = examName; - this.dataStore['attempt_number'] = latestTest['attempt_number']; + this.dataStore['attempt_number'] = latestTest.data['attempt_number']; console.log(this.dataStore); - } else if (latestTest['cmi_entry'] === 'resume') { + } else if (latestTest.data['cmi_entry'] === 'resume') { console.log("resuming test"); - const parsedExamData = JSON.parse(latestTest.exam_data || '{}'); + let parsedSuspendData = JSON.parse(latestTest.data.suspend_data || '{}'); - this.dataStore = JSON.parse(JSON.stringify(parsedExamData)); + this.dataStore = JSON.parse(JSON.stringify(parsedSuspendData)); console.log(this.dataStore); } @@ -149,7 +149,7 @@ export class NumbasLmsService { name: ExamName, attempt_number: currentAttemptNumber, pass_status: status === 'passed', - exam_data: JSON.stringify(this.dataStore), + suspend_data: JSON.stringify(this.dataStore), completed: true, exam_result: examResult, cmi_entry: cmientry, @@ -184,7 +184,7 @@ export class NumbasLmsService { return 'true'; } - //function to save the state of the exam. + // Saves the state of the exam. Commit(): string { if (!this.initializationComplete$.getValue()) { console.warn('Initialization not complete. Cannot commit.'); @@ -198,12 +198,9 @@ export class NumbasLmsService { } console.log("Committing dataStore:", this.dataStore); - // Directly stringify the dataStore - const jsonData = JSON.stringify(this.dataStore); - // Use XHR to send the request const xhr = new XMLHttpRequest(); - xhr.open('PUT', `${this.apiBaseUrl}/${this.testId}/exam_data`, true); + xhr.open('PUT', `${this.apiBaseUrl}/${this.testId}/suspend`, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = () => { @@ -218,7 +215,8 @@ export class NumbasLmsService { console.error('Request failed.'); }; - xhr.send(jsonData); + const requestData = { suspend_data: this.dataStore }; + xhr.send(JSON.stringify(requestData)); return 'true'; } diff --git a/src/app/api/services/spec/numbas-lms.service.spec.ts b/src/app/api/services/spec/numbas-lms.service.spec.ts index f456d5a89..7d29ef75e 100644 --- a/src/app/api/services/spec/numbas-lms.service.spec.ts +++ b/src/app/api/services/spec/numbas-lms.service.spec.ts @@ -55,7 +55,7 @@ describe('NumbasLmsService', () => { it('should handle review mode and get latest completed test result', () => { const mockResponse = { data: { - exam_data: JSON.stringify({ someData: 'value' }) + suspend_data: JSON.stringify({ someData: 'value' }) } }; diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index 488c0a633..f5453e0dc 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -48,6 +48,8 @@ export class NumbasComponent implements OnInit { } removeNumbasTest(): void { + const iframe = document.getElementsByTagName('iframe')[0]; + iframe?.parentNode?.removeChild(iframe); this.dialogRef.close(); } } From 48a31da1442f1e14f497c614053d9002c9f2631b Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 7 May 2024 03:00:27 +1000 Subject: [PATCH 35/84] feat: display numbas task comments --- src/app/api/models/task.ts | 6 +++ src/app/doubtfire-angular.module.ts | 2 + .../numbas-comment.component.html | 10 ++++ .../numbas-comment.component.scss | 51 +++++++++++++++++++ .../numbas-comment.component.ts | 21 ++++++++ .../task-comments-viewer.component.html | 8 +++ .../task-comments-viewer.component.scss | 2 +- .../task-comments-viewer.component.ts | 6 ++- 8 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html create mode 100644 src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.scss create mode 100644 src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 2aa167adf..403165cfe 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -507,6 +507,12 @@ export class Task extends Entity { ); } + public get numbasEnabled(): boolean { + return ( + this.definition.hasEnabledNumbasTest && this.definition.hasNumbasData + ); + } + public submissionUrl(asAttachment: boolean = false): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/projects/${ this.project.id diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 48f54027d..c634965f6 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -228,6 +228,7 @@ import {GradeService} from './common/services/grade.service'; import {NumbasComponent} from './common/numbas-component/numbas-component.component'; import {NumbasModal} from './common/numbas-component/numbas-modal.component'; import {NumbasLmsService} from './api/services/numbas-lms.service'; +import {NumbasCommentComponent} from './tasks/task-comments-viewer/numbas-comment/numbas-comment.component'; @NgModule({ // Components we declare @@ -331,6 +332,7 @@ import {NumbasLmsService} from './api/services/numbas-lms.service'; FTaskBadgeComponent, FUnitsComponent, NumbasComponent, + NumbasCommentComponent, ], // Services we provide providers: [ diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html new file mode 100644 index 000000000..d87863bee --- /dev/null +++ b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html @@ -0,0 +1,10 @@ +
+
+
+
+
+ +
+
+
+
diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.scss b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.scss new file mode 100644 index 000000000..31df73023 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.scss @@ -0,0 +1,51 @@ +div { + width: 100%; +} + +p { + color: #2c2c2c; + text-align: center; +} + +hr { + width: 100%; +} + +.hr-fade { + background: linear-gradient(to right, transparent, #9696969d, transparent); + width: 100%; + margin-top: 1px; +} + +.hr-text { + margin: 0; + line-height: 1em; + position: relative; + outline: 0; + border: 0; + color: black; + text-align: center; + height: 1.5em; + opacity: 0.8; + &:before { + content: ""; + background: linear-gradient(to right, transparent, #9696969d, transparent); + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 1px; + } + &:after { + content: attr(data-content); + position: relative; + display: inline-block; + color: black; + + padding: 0 0.5em; + line-height: 1.5em; + + color: #9696969d; + background-color: #fff; + } +} diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts new file mode 100644 index 000000000..eed512fe1 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts @@ -0,0 +1,21 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { Task, TaskComment } from 'src/app/api/models/doubtfire-model'; +import { NumbasModal } from 'src/app/common/numbas-component/numbas-modal.component'; + +@Component({ + selector: 'numbas-comment', + templateUrl: './numbas-comment.component.html', + styleUrls: ['./numbas-comment.component.scss'], +}) +export class NumbasCommentComponent implements OnInit { + @Input() task: Task; + @Input() comment: TaskComment; + + constructor(private modalService: NumbasModal) {} + + ngOnInit() {} + + reviewNumbasTest() { + this.modalService.show(this.task, 'review'); + } +} diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index d104df63a..cd18eda9b 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -72,6 +72,14 @@ > +
+ +
+
{ if ( @@ -150,7 +154,7 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit { } shouldShowAuthorIcon(commentType: string) { - return !(commentType === 'extension' || commentType === 'status' || commentType == 'assessment'); + return !(commentType === 'extension' || commentType === 'status' || commentType == 'assessment' || commentType == 'numbas'); } commentClasses(comment: TaskComment): object { From 0652b56f69eb850c2dfb43be54d07beb4c4eb469 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 7 May 2024 07:10:54 +1000 Subject: [PATCH 36/84] feat: add test attempt service and minor numbas related changes --- src/app/api/models/test-attempt.ts | 14 +++++++++++--- src/app/api/services/numbas-lms.service.ts | 12 ++++++------ src/app/api/services/task-comment.service.ts | 2 +- src/app/doubtfire-angular.module.ts | 2 ++ src/app/doubtfire-angularjs.module.ts | 2 ++ .../upload-submission-modal.coffee | 2 +- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts index 77e4af172..e6f2e5d61 100644 --- a/src/app/api/models/test-attempt.ts +++ b/src/app/api/models/test-attempt.ts @@ -1,14 +1,22 @@ import { Entity } from "ngx-entity-service"; +import { Task } from "./task"; export class TestAttempt extends Entity { - id: number; + public id: number; name: string; attemptNumber: number; passStatus: boolean; - examData: string; + suspendData: string; completed: boolean; cmiEntry: string; examResult: string; attemptedAt: Date; - associatedTaskId: number; + taskId: number; + + task: Task; + + constructor(task: Task) { + super(); + this.task = task; + } } diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index 6479a6ed9..59d94e052 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -21,7 +21,7 @@ export class NumbasLmsService { }; private testId: number = 0; - private taskId: number; + private task: Task; private learnerId: string; initializationComplete$ = new BehaviorSubject(false); @@ -37,7 +37,7 @@ export class NumbasLmsService { } setTask(task: Task) { - this.taskId = task.id; + this.task = task; } getDefaultDataStore() { @@ -56,7 +56,7 @@ export class NumbasLmsService { if (mode === 'review') { this.SetValue('cmi.mode', 'review'); - xhr.open("GET", `${this.apiBaseUrl}/completed-latest?task_id=${this.taskId}`, false); + xhr.open("GET", `${this.apiBaseUrl}/completed-latest?task_id=${this.task.id}`, false); xhr.send(); console.log(xhr.responseText); @@ -88,7 +88,7 @@ export class NumbasLmsService { } } - xhr.open("GET", `${this.apiBaseUrl}/latest?task_id=${this.taskId}`, false); + xhr.open("GET", `${this.apiBaseUrl}/latest?task_id=${this.task.id}`, false); xhr.send(); console.log(xhr.responseText); @@ -139,7 +139,7 @@ export class NumbasLmsService { Terminate(): string { console.log('Terminate Called'); const examResult = this.dataStore["cmi.score.raw"]; - const status = this.GetValue("cmi.completion_status"); + const status = this.GetValue("cmi.success_status"); this.dataStore['completed'] = true; const currentAttemptNumber = this.dataStore['attempt_number'] || 0; const ExamName = this.dataStore['name']; @@ -153,7 +153,7 @@ export class NumbasLmsService { completed: true, exam_result: examResult, cmi_entry: cmientry, - task_id: this.taskId + task_id: this.task.id }; const xhr = new XMLHttpRequest(); diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts index 9e646be77..e3c80797a 100644 --- a/src/app/api/services/task-comment.service.ts +++ b/src/app/api/services/task-comment.service.ts @@ -145,7 +145,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 === 'numbas') { body.append('comment', data); } else if (commentType === 'discussion') { opts.endpointFormat = this.discussionEndpointFormat; diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index c634965f6..e1f4d2356 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -229,6 +229,7 @@ import {NumbasComponent} from './common/numbas-component/numbas-component.compon import {NumbasModal} from './common/numbas-component/numbas-modal.component'; import {NumbasLmsService} from './api/services/numbas-lms.service'; import {NumbasCommentComponent} from './tasks/task-comments-viewer/numbas-comment/numbas-comment.component'; +import {TestAttemptService} from './api/services/test-attempt.service'; @NgModule({ // Components we declare @@ -406,6 +407,7 @@ import {NumbasCommentComponent} from './tasks/task-comments-viewer/numbas-commen CreateNewUnitModal, NumbasModal, NumbasLmsService, + TestAttemptService, provideLottieOptions({ player: () => player, }), diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index b88fbeb29..fa817ced4 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -227,6 +227,7 @@ import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; import {NumbasComponent} from './common/numbas-component/numbas-component.component'; import {NumbasModal} from './common/numbas-component/numbas-modal.component'; +import {TestAttemptService} from './api/services/test-attempt.service'; export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', @@ -310,6 +311,7 @@ DoubtfireAngularJSModule.factory( ); DoubtfireAngularJSModule.factory('CreateNewUnitModal', downgradeInjectable(CreateNewUnitModal)); DoubtfireAngularJSModule.factory('NumbasModal', downgradeInjectable(NumbasModal)); +DoubtfireAngularJSModule.factory('testAttemptService', downgradeInjectable(TestAttemptService)); // directive -> component DoubtfireAngularJSModule.directive( diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index 68d742362..f9552b356 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -128,7 +128,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) removed.push('group') if !isRFF || !task.isGroupTask() removed.push('alignment') if !isRFF || !task.unit.ilos.length > 0 removed.push('comments') if isTestSubmission - removed.push('numbas') if !task.definition.hasEnabledNumbasTest + removed.push('numbas') if !isRFF || !task.definition.hasEnabledNumbasTest removed # Initialises the states initialise: -> From a576f484bc434728eab9632c022bccf1ed26cb01 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 7 May 2024 07:11:52 +1000 Subject: [PATCH 37/84] feat: add test attempt service --- src/app/api/services/test-attempt.service.ts | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/app/api/services/test-attempt.service.ts 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..094f2baa0 --- /dev/null +++ b/src/app/api/services/test-attempt.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from "@angular/core"; +import { EntityService } from "ngx-entity-service"; +import { TestAttempt } from "../models/test-attempt"; +import { HttpClient } from "@angular/common/http"; +import API_URL from "src/app/config/constants/apiURL"; +import { Task } from "../models/task"; +import { Observable } from "rxjs"; +import { AppInjector } from "src/app/app-injector"; +import { DoubtfireConstants } from "src/app/config/constants/doubtfire-constants"; + +@Injectable() +export class TestAttemptService extends EntityService { + protected readonly endpointFormat = '/test_attempts?id=:id:'; + + constructor(httpClient: HttpClient) { + super(httpClient, API_URL); + + this.mapping.addKeys( + 'id', + 'name', + 'attemptNumber', + 'passStatus', + 'suspendData', + 'completed', + 'cmiEntry', + 'examResult', + 'attemptedAt', + 'taskId' + ); + } + + public createInstanceFrom(json: object, other?: any): TestAttempt { + return new TestAttempt(other as Task); + } + + public getLatestCompletedTestAttempt(task: Task): Observable { + const url = `${AppInjector.get(DoubtfireConstants).API_URL}/test_attempts/completed-latest?task_id=${task.id}`; + return AppInjector.get(HttpClient).get(url); + } +} \ No newline at end of file From 2b1dcfc717eb770dd623c62ac99d8d961d1c3124 Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Tue, 7 May 2024 16:29:44 +1000 Subject: [PATCH 38/84] fix: ensure counters are incremented after object creation --- src/app/api/services/numbas-lms.service.ts | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/numbas-lms.service.ts index 59d94e052..5593fba3f 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/numbas-lms.service.ts @@ -13,16 +13,16 @@ export class NumbasLmsService { private defaultValues: { [key: string]: string } = { 'cmi.completion_status': 'not attempted', 'cmi.entry': 'ab-initio', + 'cmi.objectives._count': '0', + 'cmi.interactions._count': '0', 'numbas.user_role': 'learner', - 'numbas.duration_extension.units': 'seconds', 'cmi.mode': 'normal', - 'cmi.undefinedlearner_response': '1', - 'cmi.undefinedresult': '0' }; private testId: number = 0; private task: Task; - private learnerId: string; + private readonly learnerId: string; + private readonly learnerName: string; initializationComplete$ = new BehaviorSubject(false); private scormErrors: { [key: string]: string } = { @@ -33,7 +33,9 @@ export class NumbasLmsService { dataStore: { [key: string]: any } = this.getDefaultDataStore(); constructor(private userService: UserService) { - this.learnerId = this.userService.currentUser.studentId; + const user = this.userService.currentUser; + this.learnerId = user.studentId; + this.learnerName = user.firstName + user.lastName; } setTask(task: Task) { @@ -106,6 +108,7 @@ export class NumbasLmsService { if (latestTest.data['cmi_entry'] === 'ab-initio') { console.log("starting new test"); this.SetValue('cmi.learner_id', this.learnerId); + this.SetValue('cmi.learner_name', this.learnerName); this.dataStore['name'] = examName; this.dataStore['attempt_number'] = latestTest.data['attempt_number']; console.log(this.dataStore); @@ -178,9 +181,17 @@ export class NumbasLmsService { } SetValue(element: string, value: any): string { - if (element.startsWith('cmi.')) { - this.dataStore[element] = value; + console.log(`SetValue:`, element, value); + this.dataStore[element] = value; + if (element.match('cmi.interactions.\\d+.id')) { + console.log('Incrementing cmi.interactions._count'); + this.dataStore['cmi.interactions._count']++; } + if (element.match('cmi.objectives.\\d+.id')) { + console.log('Incrementing cmi.objectives._count'); + this.dataStore['cmi.objectives._count']++; + } + // console.log("dataStore after value set:", this.dataStore); return 'true'; } From aa20a273f6a4e8e0f597da164adb330deaff470c Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Tue, 7 May 2024 23:20:13 +1000 Subject: [PATCH 39/84] refactor: rename scorm service --- .../{numbas-lms.service.ts => scorm-lms.service.ts} | 2 +- src/app/api/services/spec/numbas-lms.service.spec.ts | 8 ++++---- .../common/numbas-component/numbas-component.component.ts | 4 ++-- src/app/doubtfire-angular.module.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) rename src/app/api/services/{numbas-lms.service.ts => scorm-lms.service.ts} (99%) diff --git a/src/app/api/services/numbas-lms.service.ts b/src/app/api/services/scorm-lms.service.ts similarity index 99% rename from src/app/api/services/numbas-lms.service.ts rename to src/app/api/services/scorm-lms.service.ts index 5593fba3f..bd7b5f108 100644 --- a/src/app/api/services/numbas-lms.service.ts +++ b/src/app/api/services/scorm-lms.service.ts @@ -7,7 +7,7 @@ import { Task } from '../models/task'; @Injectable({ providedIn: 'root' }) -export class NumbasLmsService { +export class ScormLmsService { private readonly apiBaseUrl = `${API_URL}/test_attempts`; private defaultValues: { [key: string]: string } = { diff --git a/src/app/api/services/spec/numbas-lms.service.spec.ts b/src/app/api/services/spec/numbas-lms.service.spec.ts index 7d29ef75e..f38d5fc08 100644 --- a/src/app/api/services/spec/numbas-lms.service.spec.ts +++ b/src/app/api/services/spec/numbas-lms.service.spec.ts @@ -1,12 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { NumbasLmsService } from '../numbas-lms.service'; +import { ScormLmsService } from '../scorm-lms.service'; import { TaskService } from '../task.service'; import { UserService } from '../user.service'; import { of } from 'rxjs'; describe('NumbasLmsService', () => { - let service: NumbasLmsService; + let service: ScormLmsService; let httpTestingController: HttpTestingController; let mockUserService: Partial; let mockTaskService: Partial; @@ -27,13 +27,13 @@ describe('NumbasLmsService', () => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ - NumbasLmsService, + ScormLmsService, { provide: UserService, useValue: mockUserService }, { provide: TaskService, useValue: mockTaskService } ] }); - service = TestBed.inject(NumbasLmsService); + service = TestBed.inject(ScormLmsService); httpTestingController = TestBed.inject(HttpTestingController); }); diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/numbas-component/numbas-component.component.ts index f5453e0dc..2f977ef60 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/numbas-component/numbas-component.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Task } from 'src/app/api/models/task'; -import { NumbasLmsService } from 'src/app/api/services/numbas-lms.service'; +import { ScormLmsService } from 'src/app/api/services/scorm-lms.service'; import { AppInjector } from 'src/app/app-injector'; import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; @@ -23,7 +23,7 @@ export class NumbasComponent implements OnInit { constructor( private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: { task: Task, mode: 'attempt' | 'review' }, - private lmsService: NumbasLmsService, + private lmsService: ScormLmsService, private sanitizer: DomSanitizer ) {} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index e1f4d2356..e5b2e1b2a 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -227,7 +227,7 @@ import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {GradeService} from './common/services/grade.service'; import {NumbasComponent} from './common/numbas-component/numbas-component.component'; import {NumbasModal} from './common/numbas-component/numbas-modal.component'; -import {NumbasLmsService} from './api/services/numbas-lms.service'; +import {ScormLmsService} from './api/services/scorm-lms.service'; import {NumbasCommentComponent} from './tasks/task-comments-viewer/numbas-comment/numbas-comment.component'; import {TestAttemptService} from './api/services/test-attempt.service'; @@ -406,7 +406,7 @@ import {TestAttemptService} from './api/services/test-attempt.service'; IsActiveUnitRole, CreateNewUnitModal, NumbasModal, - NumbasLmsService, + ScormLmsService, TestAttemptService, provideLottieOptions({ player: () => player, From fdd5667d1744f5ad135bba946a8d2daee6648605 Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Wed, 8 May 2024 22:08:12 +1000 Subject: [PATCH 40/84] refactor: generalize scorm player components and services --- src/app/ajs-upgraded-providers.ts | 10 +++--- ...ms.service.ts => scorm-adapter.service.ts} | 8 ++--- ....spec.ts => scorm-adapter.service.spec.ts} | 11 +++--- .../scorm-player-modal.component.ts} | 10 +++--- .../scorm-player.component.html} | 4 +-- .../scorm-player.component.scss} | 0 .../scorm-player.component.spec.ts} | 12 +++---- .../scorm-player.component.ts} | 36 ++++++++++--------- src/app/doubtfire-angular.module.ts | 12 +++---- src/app/doubtfire-angularjs.module.ts | 11 +++--- .../upload-submission-modal.coffee | 11 +++--- .../upload-submission-modal.tpl.html | 8 ++--- .../numbas-comment.component.ts | 4 +-- 13 files changed, 71 insertions(+), 66 deletions(-) rename src/app/api/services/{scorm-lms.service.ts => scorm-adapter.service.ts} (97%) rename src/app/api/services/spec/{numbas-lms.service.spec.ts => scorm-adapter.service.spec.ts} (91%) rename src/app/common/{numbas-component/numbas-modal.component.ts => scorm-player/scorm-player-modal.component.ts} (64%) rename src/app/common/{numbas-component/numbas-component.component.html => scorm-player/scorm-player.component.html} (74%) rename src/app/common/{numbas-component/numbas-component.component.scss => scorm-player/scorm-player.component.scss} (100%) rename src/app/common/{numbas-component/numbas-component.component.spec.ts => scorm-player/scorm-player.component.spec.ts} (51%) rename src/app/common/{numbas-component/numbas-component.component.ts => scorm-player/scorm-player.component.ts} (50%) diff --git a/src/app/ajs-upgraded-providers.ts b/src/app/ajs-upgraded-providers.ts index 9d5b0f020..f114da1c7 100644 --- a/src/app/ajs-upgraded-providers.ts +++ b/src/app/ajs-upgraded-providers.ts @@ -18,7 +18,7 @@ export const rootScope = new InjectionToken('$rootScope'); export const calendarModal = new InjectionToken('CalendarModal'); export const aboutDoubtfireModal = new InjectionToken('AboutDoubtfireModal'); export const plagiarismReportModal = new InjectionToken('PlagiarismReportModal'); -export const numbasModal = new InjectionToken('NumbasModal'); +export const scormPlayerModal = new InjectionToken('ScormPlayerModal'); // Define a provider for the above injection token... // It will get the service from AngularJS via the factory @@ -118,8 +118,8 @@ export const UnitStudentEnrolmentModalProvider = { deps: ['$injector'], }; -export const numbasModalProvider = { - provide: numbasModal, - useFactory: (i) => i.get('NumbasModal'), +export const ScormPlayerModalProvider = { + provide: scormPlayerModal, + useFactory: (i) => i.get('ScormPlayerModal'), deps: ['$injector'], -}; \ No newline at end of file +}; diff --git a/src/app/api/services/scorm-lms.service.ts b/src/app/api/services/scorm-adapter.service.ts similarity index 97% rename from src/app/api/services/scorm-lms.service.ts rename to src/app/api/services/scorm-adapter.service.ts index bd7b5f108..320233180 100644 --- a/src/app/api/services/scorm-lms.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -7,7 +7,7 @@ import { Task } from '../models/task'; @Injectable({ providedIn: 'root' }) -export class ScormLmsService { +export class ScormAdapterService { private readonly apiBaseUrl = `${API_URL}/test_attempts`; private defaultValues: { [key: string]: string } = { @@ -207,7 +207,7 @@ export class ScormLmsService { if (!this.isTestCompleted()) { this.dataStore['cmi.exit'] = 'suspend'; } - console.log("Committing dataStore:", this.dataStore); + console.log("Committing DataModel:", this.dataStore); // Use XHR to send the request const xhr = new XMLHttpRequest(); @@ -216,9 +216,9 @@ export class ScormLmsService { xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 400) { - console.log('Suspend data saved successfully.'); + console.log('DataModel saved successfully.'); } else { - console.error('Error saving suspend data:', xhr.responseText); + console.error('Error saving DataModel:', xhr.responseText); } }; diff --git a/src/app/api/services/spec/numbas-lms.service.spec.ts b/src/app/api/services/spec/scorm-adapter.service.spec.ts similarity index 91% rename from src/app/api/services/spec/numbas-lms.service.spec.ts rename to src/app/api/services/spec/scorm-adapter.service.spec.ts index f38d5fc08..5d3a52caa 100644 --- a/src/app/api/services/spec/numbas-lms.service.spec.ts +++ b/src/app/api/services/spec/scorm-adapter.service.spec.ts @@ -1,12 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { ScormLmsService } from '../scorm-lms.service'; +import { ScormAdapterService } from '../scorm-adapter.service'; import { TaskService } from '../task.service'; import { UserService } from '../user.service'; -import { of } from 'rxjs'; -describe('NumbasLmsService', () => { - let service: ScormLmsService; +describe('ScormAdapterService', () => { + let service: ScormAdapterService; let httpTestingController: HttpTestingController; let mockUserService: Partial; let mockTaskService: Partial; @@ -27,13 +26,13 @@ describe('NumbasLmsService', () => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [ - ScormLmsService, + ScormAdapterService, { provide: UserService, useValue: mockUserService }, { provide: TaskService, useValue: mockTaskService } ] }); - service = TestBed.inject(ScormLmsService); + service = TestBed.inject(ScormAdapterService); httpTestingController = TestBed.inject(HttpTestingController); }); diff --git a/src/app/common/numbas-component/numbas-modal.component.ts b/src/app/common/scorm-player/scorm-player-modal.component.ts similarity index 64% rename from src/app/common/numbas-component/numbas-modal.component.ts rename to src/app/common/scorm-player/scorm-player-modal.component.ts index 73b2122c1..443add9be 100644 --- a/src/app/common/numbas-component/numbas-modal.component.ts +++ b/src/app/common/scorm-player/scorm-player-modal.component.ts @@ -1,18 +1,18 @@ import { Injectable } from '@angular/core'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { NumbasComponent } from './numbas-component.component'; +import { ScormPlayerComponent } from './scorm-player.component'; import { Task } from 'src/app/api/models/task'; @Injectable({ providedIn: 'root', }) -export class NumbasModal { +export class ScormPlayerModal { constructor(public dialog: MatDialog) { } - + public show(task: Task, mode: 'attempt' | 'review'): void { - let dialogRef: MatDialogRef; + let dialogRef: MatDialogRef; - dialogRef = this.dialog.open(NumbasComponent, { + dialogRef = this.dialog.open(ScormPlayerComponent, { data: { task, mode }, width: '95%', height: '90%' }); diff --git a/src/app/common/numbas-component/numbas-component.component.html b/src/app/common/scorm-player/scorm-player.component.html similarity index 74% rename from src/app/common/numbas-component/numbas-component.component.html rename to src/app/common/scorm-player/scorm-player.component.html index d562f0223..5089ad9de 100644 --- a/src/app/common/numbas-component/numbas-component.component.html +++ b/src/app/common/scorm-player/scorm-player.component.html @@ -1,4 +1,4 @@
- -
\ No newline at end of file + +
diff --git a/src/app/common/numbas-component/numbas-component.component.scss b/src/app/common/scorm-player/scorm-player.component.scss similarity index 100% rename from src/app/common/numbas-component/numbas-component.component.scss rename to src/app/common/scorm-player/scorm-player.component.scss diff --git a/src/app/common/numbas-component/numbas-component.component.spec.ts b/src/app/common/scorm-player/scorm-player.component.spec.ts similarity index 51% rename from src/app/common/numbas-component/numbas-component.component.spec.ts rename to src/app/common/scorm-player/scorm-player.component.spec.ts index 31dad5e30..7980df7c3 100644 --- a/src/app/common/numbas-component/numbas-component.component.spec.ts +++ b/src/app/common/scorm-player/scorm-player.component.spec.ts @@ -1,18 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NumbasComponent } from './numbas-component.component'; +import { ScormPlayerComponent } from './scorm-player.component'; -describe('NumbasComponent', () => { - let component: NumbasComponent; - let fixture: ComponentFixture; +describe('ScormPlayerComponent', () => { + let component: ScormPlayerComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ NumbasComponent ] + declarations: [ ScormPlayerComponent ] }) .compileComponents(); - fixture = TestBed.createComponent(NumbasComponent); + fixture = TestBed.createComponent(ScormPlayerComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/common/numbas-component/numbas-component.component.ts b/src/app/common/scorm-player/scorm-player.component.ts similarity index 50% rename from src/app/common/numbas-component/numbas-component.component.ts rename to src/app/common/scorm-player/scorm-player.component.ts index 2f977ef60..7ed98b3cf 100644 --- a/src/app/common/numbas-component/numbas-component.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { Task } from 'src/app/api/models/task'; -import { ScormLmsService } from 'src/app/api/services/scorm-lms.service'; +import { ScormAdapterService } from 'src/app/api/services/scorm-adapter.service'; import { AppInjector } from 'src/app/app-injector'; import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; @@ -11,35 +11,35 @@ declare global { } @Component({ - selector: 'f-numbas-component', - templateUrl: './numbas-component.component.html', - styleUrls: ['./numbas-component.component.scss'], + selector: 'f-scorm-player', + templateUrl: './scorm-player.component.html', + styleUrls: ['./scorm-player.component.scss'], }) -export class NumbasComponent implements OnInit { +export class ScormPlayerComponent implements OnInit { task: Task; currentMode: 'attempt' | 'review' = 'attempt'; iframeSrc: SafeResourceUrl; constructor( - private dialogRef: MatDialogRef, + private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: { task: Task, mode: 'attempt' | 'review' }, - private lmsService: ScormLmsService, + private scormAdapter: ScormAdapterService, private sanitizer: DomSanitizer ) {} ngOnInit(): void { this.task = this.data.task; - this.lmsService.setTask(this.task); + this.scormAdapter.setTask(this.task); window.API_1484_11 = { - Initialize: () => this.lmsService.Initialize(this.currentMode), - Terminate: () => this.lmsService.Terminate(), - GetValue: (element: string) => this.lmsService.GetValue(element), - SetValue: (element: string, value: string) => this.lmsService.SetValue(element, value), - Commit: () => this.lmsService.Commit(), - GetLastError: () => this.lmsService.GetLastError(), - GetErrorString: (errorCode: string) => this.lmsService.GetErrorString(errorCode), - GetDiagnostic: (errorCode: string) => this.lmsService.GetDiagnostic(errorCode) + Initialize: () => this.scormAdapter.Initialize(this.currentMode), + Terminate: () => this.scormAdapter.Terminate(), + GetValue: (element: string) => this.scormAdapter.GetValue(element), + SetValue: (element: string, value: string) => this.scormAdapter.SetValue(element, value), + Commit: () => this.scormAdapter.Commit(), + GetLastError: () => this.scormAdapter.GetLastError(), + GetErrorString: (errorCode: string) => this.scormAdapter.GetErrorString(errorCode), + GetDiagnostic: (errorCode: string) => this.scormAdapter.GetDiagnostic(errorCode) }; this.currentMode = this.data.mode; @@ -47,7 +47,9 @@ export class NumbasComponent implements OnInit { this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(`${AppInjector.get(DoubtfireConstants).API_URL}/numbas_api/${this.task.taskDefId}/index.html`); } - removeNumbasTest(): void { + close(): void { + console.log('SCORM player closing, commiting DataModel!'); + this.scormAdapter.Commit(); const iframe = document.getElementsByTagName('iframe')[0]; iframe?.parentNode?.removeChild(iframe); this.dialogRef.close(); diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index e5b2e1b2a..23145ccae 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -72,6 +72,7 @@ import { gradeTaskModalProvider, uploadSubmissionModalProvider, ConfirmationModalProvider, + ScormPlayerModalProvider, } from './ajs-upgraded-providers'; import { TaskCommentComposerComponent, @@ -225,9 +226,8 @@ import {FTaskSheetViewComponent} from './units/states/tasks/viewer/directives/f- import {TasksViewerComponent} from './units/states/tasks/tasks-viewer/tasks-viewer.component'; import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {GradeService} from './common/services/grade.service'; -import {NumbasComponent} from './common/numbas-component/numbas-component.component'; -import {NumbasModal} from './common/numbas-component/numbas-modal.component'; -import {ScormLmsService} from './api/services/scorm-lms.service'; +import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; +import {ScormAdapterService} from './api/services/scorm-adapter.service'; import {NumbasCommentComponent} from './tasks/task-comments-viewer/numbas-comment/numbas-comment.component'; import {TestAttemptService} from './api/services/test-attempt.service'; @@ -332,7 +332,7 @@ import {TestAttemptService} from './api/services/test-attempt.service'; FUsersComponent, FTaskBadgeComponent, FUnitsComponent, - NumbasComponent, + ScormPlayerComponent, NumbasCommentComponent, ], // Services we provide @@ -405,8 +405,8 @@ import {TestAttemptService} from './api/services/test-attempt.service'; TasksForInboxSearchPipe, IsActiveUnitRole, CreateNewUnitModal, - NumbasModal, - ScormLmsService, + ScormPlayerModalProvider, + ScormAdapterService, TestAttemptService, provideLottieOptions({ player: () => player, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index fa817ced4..3cd6555bd 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -225,8 +225,8 @@ import {FUnitsComponent} from './admin/states/f-units/f-units.component'; import {MarkedPipe} from './common/pipes/marked.pipe'; import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; -import {NumbasComponent} from './common/numbas-component/numbas-component.component'; -import {NumbasModal} from './common/numbas-component/numbas-modal.component'; +import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; +import {ScormPlayerModal} from './common/scorm-player/scorm-player-modal.component'; import {TestAttemptService} from './api/services/test-attempt.service'; export const DoubtfireAngularJSModule = angular.module('doubtfire', [ @@ -310,7 +310,7 @@ DoubtfireAngularJSModule.factory( downgradeInjectable(EditProfileDialogService), ); DoubtfireAngularJSModule.factory('CreateNewUnitModal', downgradeInjectable(CreateNewUnitModal)); -DoubtfireAngularJSModule.factory('NumbasModal', downgradeInjectable(NumbasModal)); +DoubtfireAngularJSModule.factory('ScormPlayerModal', downgradeInjectable(ScormPlayerModal)); DoubtfireAngularJSModule.factory('testAttemptService', downgradeInjectable(TestAttemptService)); // directive -> component @@ -446,7 +446,10 @@ DoubtfireAngularJSModule.directive( ); DoubtfireAngularJSModule.directive('fUnits', downgradeComponent({component: FUnitsComponent})); -DoubtfireAngularJSModule.directive('fNumbasComponent', downgradeComponent({component: NumbasComponent})); +DoubtfireAngularJSModule.directive( + 'fScormPlayerComponent', + downgradeComponent({component: ScormPlayerComponent}), +); // Global configuration DoubtfireAngularJSModule.directive( diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index f9552b356..6a17bd558 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -32,7 +32,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) UploadSubmissionModal ) -.controller('UploadSubmissionModalCtrl', ($scope, $rootScope, $timeout, $modalInstance, NumbasModal, newTaskService, newProjectService, task, reuploadEvidence, outcomeService, PrivacyPolicy) -> +.controller('UploadSubmissionModalCtrl', ($scope, $rootScope, $timeout, $modalInstance, ScormPlayerModal, newTaskService, newProjectService, task, reuploadEvidence, outcomeService, PrivacyPolicy) -> $scope.privacyPolicy = PrivacyPolicy # Expose task to scope $scope.task = task @@ -100,7 +100,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) # States functionality states = { # All possible states - all: ['group', 'numbas', 'files', 'alignment', 'comments', 'uploading'] + all: ['group', 'scorm-assessment', 'files', 'alignment', 'comments', 'uploading'] # Only states which are shown (populated in initialise) shown: [] # The currently active state (set in initialise) @@ -128,7 +128,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) removed.push('group') if !isRFF || !task.isGroupTask() removed.push('alignment') if !isRFF || !task.unit.ilos.length > 0 removed.push('comments') if isTestSubmission - removed.push('numbas') if !isRFF || !task.definition.hasEnabledNumbasTest + removed.push('scorm-assessment') if !isRFF || !task.definition.hasEnabledNumbasTest removed # Initialises the states initialise: -> @@ -155,8 +155,9 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) previous: states.previous } - $scope.launchNumbasDialog = -> - NumbasModal.show $scope.task, 'attempt' + $scope.launchScormPlayer = -> + console.clear() + ScormPlayerModal.show $scope.task, 'attempt' # Whether or not we should disable this button $scope.shouldDisableBtn = { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index 0527aadd4..8b6c89bea 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -44,9 +44,9 @@

+ class="state state-scorm-assessment" + ng-class="{'state-hidden-left': isHidden('scorm-assessment').left, + 'state-hidden-right': isHidden('scorm-assessment').right}">

@@ -57,7 +57,7 @@

-
diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts index eed512fe1..650a110d0 100644 --- a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, Input } from '@angular/core'; import { Task, TaskComment } from 'src/app/api/models/doubtfire-model'; -import { NumbasModal } from 'src/app/common/numbas-component/numbas-modal.component'; +import { ScormPlayerModal } from 'src/app/common/scorm-player/scorm-player-modal.component'; @Component({ selector: 'numbas-comment', @@ -11,7 +11,7 @@ export class NumbasCommentComponent implements OnInit { @Input() task: Task; @Input() comment: TaskComment; - constructor(private modalService: NumbasModal) {} + constructor(private modalService: ScormPlayerModal) {} ngOnInit() {} From 226d9193251fb675c92bec8265508477857a4ec9 Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Wed, 8 May 2024 22:29:37 +1000 Subject: [PATCH 41/84] fix: use nullish coalescing when retrieving data from the datamodel also disallow dismissing modal to ensure datamodel is committed --- src/app/api/services/scorm-adapter.service.ts | 10 +++++----- .../scorm-player/scorm-player-modal.component.ts | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index 320233180..22c0205a3 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -69,7 +69,7 @@ export class ScormAdapterService { try { const completedTest = JSON.parse(xhr.responseText); - let parsedSuspendData = JSON.parse(completedTest.data.suspend_data || '{}'); + let parsedSuspendData = JSON.parse(completedTest.data.suspend_data ?? '{}'); // Set entire suspendData string to cmi.suspend_data this.SetValue('cmi.suspend_data', JSON.stringify(parsedSuspendData)); @@ -114,7 +114,7 @@ export class ScormAdapterService { console.log(this.dataStore); } else if (latestTest.data['cmi_entry'] === 'resume') { console.log("resuming test"); - let parsedSuspendData = JSON.parse(latestTest.data.suspend_data || '{}'); + let parsedSuspendData = JSON.parse(latestTest.data.suspend_data ?? '{}'); this.dataStore = JSON.parse(JSON.stringify(parsedSuspendData)); @@ -132,7 +132,7 @@ export class ScormAdapterService { } isTestCompleted(): boolean { - return this.dataStore?.['completed'] || false; + return this.dataStore?.['completed'] ?? false; } private resetDataStore() { @@ -144,7 +144,7 @@ export class ScormAdapterService { const examResult = this.dataStore["cmi.score.raw"]; const status = this.GetValue("cmi.success_status"); this.dataStore['completed'] = true; - const currentAttemptNumber = this.dataStore['attempt_number'] || 0; + const currentAttemptNumber = this.dataStore['attempt_number'] ?? 0; const ExamName = this.dataStore['name']; this.SetValue('cmi.entry', 'RO'); const cmientry = this.GetValue('cmi.entry'); @@ -177,7 +177,7 @@ export class ScormAdapterService { } GetValue(element: string): string { - return this.dataStore[element] || ''; + return this.dataStore[element] ?? ''; } SetValue(element: string, value: any): string { diff --git a/src/app/common/scorm-player/scorm-player-modal.component.ts b/src/app/common/scorm-player/scorm-player-modal.component.ts index 443add9be..190e7bf57 100644 --- a/src/app/common/scorm-player/scorm-player-modal.component.ts +++ b/src/app/common/scorm-player/scorm-player-modal.component.ts @@ -14,7 +14,8 @@ export class ScormPlayerModal { dialogRef = this.dialog.open(ScormPlayerComponent, { data: { task, mode }, - width: '95%', height: '90%' + width: '95%', height: '90%', + disableClose: true, }); } } From 20472257c87711955b79008fa3cb7517151a4424 Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Thu, 9 May 2024 06:18:20 +1000 Subject: [PATCH 42/84] refactor: separate out scorm datamodel and player context --- src/app/api/models/doubtfire-model.ts | 2 + src/app/api/models/scorm-datamodel.ts | 58 ++++++ src/app/api/models/scorm-player-context.ts | 33 +++ src/app/api/services/scorm-adapter.service.ts | 190 +++++++++--------- .../scorm-player/scorm-player.component.ts | 4 +- 5 files changed, 193 insertions(+), 94 deletions(-) create mode 100644 src/app/api/models/scorm-datamodel.ts create mode 100644 src/app/api/models/scorm-player-context.ts diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index 3cb491108..baca2f22d 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -32,6 +32,8 @@ 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'; // Users -- are students or staff export * from './user/user'; diff --git a/src/app/api/models/scorm-datamodel.ts b/src/app/api/models/scorm-datamodel.ts new file mode 100644 index 000000000..e9d105f3c --- /dev/null +++ b/src/app/api/models/scorm-datamodel.ts @@ -0,0 +1,58 @@ +export class ScormDataModel { + initState: {[key: string]: string} = { + 'cmi.completion_status': 'not attempted', + 'cmi.entry': 'ab-initio', + 'cmi.objectives._count': '0', + 'cmi.interactions._count': '0', + 'cmi.mode': 'normal', + }; + + dataModel: {[key: string]: any} = {}; + readonly msgPrefix = 'SCORM DataModel: '; + + constructor() { + this.dataModel = {}; + } + + public init() { + console.log(this.msgPrefix + 'initializing DataModel with default values'); + this.dataModel = this.initState; + } + + public restore(dataModel: {[key: string]: any} = {}) { + console.log(this.msgPrefix + 'restoring DataModel with provided data'); + this.dataModel = dataModel; + } + + public get(key: string): string { + 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')) { + const interactionPath = key.match('cmi.interactions.\\d+'); + const objectivesCounterForInteraction = interactionPath.toString() + '.objectives._count'; + console.log('Incrementing cmi.interactions._count'); + this.dataModel['cmi.interactions._count']++; + 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'; + console.log(`Incrementing ${objectivesCounterForInteraction}`); + this.dataModel[objectivesCounterForInteraction.toString()]++; + } + if (key.match('cmi.objectives.\\d+.id')) { + 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..5f1c4bd3b --- /dev/null +++ b/src/app/api/models/scorm-player-context.ts @@ -0,0 +1,33 @@ +import { Task, User } from 'src/app/api/models/doubtfire-model'; + +export class ScormPlayerContext { + task: Task; + mode: 'browse' | 'normal' | 'review'; + user: User; + attemptNumber: number; + attemptId: number; + learnerName: string; + learnerId: number; + + constructor(user: User) { + this.user = user; + this.learnerId = user.id; + this.learnerName = user.firstName + ' ' + user.lastName; + } + + public setTask(task: Task): void { + this.task = task; + } + + public setMode(mode: 'browse' | 'normal' | 'review'): void { + this.mode = mode; + } + + public setAttemptNumber(attemptNumber: number = 1): void { + this.attemptNumber = attemptNumber; + } + + public setAttemptId(attemptId: number): void { + this.attemptId = attemptId; + } +} diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index 22c0205a3..6c5353746 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -2,63 +2,76 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { UserService } from './user.service'; import API_URL from 'src/app/config/constants/apiURL'; -import { Task } from '../models/task'; +import { Task, ScormDataModel, ScormPlayerContext } from 'src/app/api/models/doubtfire-model'; @Injectable({ providedIn: 'root' }) export class ScormAdapterService { private readonly apiBaseUrl = `${API_URL}/test_attempts`; + private dataModel: ScormDataModel; + private playerContext: ScormPlayerContext; - private defaultValues: { [key: string]: string } = { - 'cmi.completion_status': 'not attempted', - 'cmi.entry': 'ab-initio', - 'cmi.objectives._count': '0', - 'cmi.interactions._count': '0', - 'numbas.user_role': 'learner', - 'cmi.mode': 'normal', - }; - - private testId: number = 0; - private task: Task; - private readonly learnerId: string; - private readonly learnerName: string; initializationComplete$ = new BehaviorSubject(false); - private scormErrors: { [key: string]: string } = { - "0": "No error", - "101": "General exception", + private scormErrorCodes: {[key: string]: string} = { + '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', }; - dataStore: { [key: string]: any } = this.getDefaultDataStore(); - constructor(private userService: UserService) { - const user = this.userService.currentUser; - this.learnerId = user.studentId; - this.learnerName = user.firstName + user.lastName; + this.dataModel = new ScormDataModel(); + this.playerContext = new ScormPlayerContext(this.userService.currentUser); } setTask(task: Task) { - this.task = task; + this.playerContext.setTask(task); } - getDefaultDataStore() { - // Use spread operator to merge defaultValues into the dataStore - return { - ...this.defaultValues, - pass_status: false, - completed: false, - }; - } + // getDefaultDataStore() { + // // Use spread operator to merge defaultValues into the dataStore + // return { + // ...this.defaultValues, + // pass_status: false, + // completed: false, + // }; + // } Initialize(mode: 'attempt' | 'review' = 'attempt'): string { console.log('Initialize() function called'); - const examName = 'test Exam Name 1'; - let xhr = new XMLHttpRequest(); + const xhr = new XMLHttpRequest(); if (mode === 'review') { this.SetValue('cmi.mode', 'review'); - xhr.open("GET", `${this.apiBaseUrl}/completed-latest?task_id=${this.task.id}`, false); + xhr.open( + 'GET', + `${this.apiBaseUrl}/completed-latest?task_id=${this.playerContext.task.id}`, + false, + ); xhr.send(); console.log(xhr.responseText); @@ -69,15 +82,17 @@ export class ScormAdapterService { try { const completedTest = JSON.parse(xhr.responseText); - let parsedSuspendData = JSON.parse(completedTest.data.suspend_data ?? '{}'); + const parsedDataModel = JSON.parse(completedTest.data.suspend_data ?? '{}'); // Set entire suspendData string to cmi.suspend_data - this.SetValue('cmi.suspend_data', JSON.stringify(parsedSuspendData)); + this.SetValue('cmi.suspend_data', JSON.stringify(parsedDataModel)); - // Use SetValue to set parsedSuspendData values to dataStore - Object.keys(parsedSuspendData).forEach(key => { - this.SetValue(key, parsedSuspendData[key]); - }); + // // Use SetValue to set parsedSuspendData values to dataStore + // Object.keys(parsedDataModel).forEach((key) => { + // this.SetValue(key, parsedDataModel[key]); + // }); + + this.dataModel.restore(parsedDataModel); this.SetValue('cmi.entry', 'RO'); this.SetValue('cmi.mode', 'review'); @@ -90,7 +105,7 @@ export class ScormAdapterService { } } - xhr.open("GET", `${this.apiBaseUrl}/latest?task_id=${this.task.id}`, false); + xhr.open('GET', `${this.apiBaseUrl}/latest?task_id=${this.playerContext.task.id}`, false); xhr.send(); console.log(xhr.responseText); @@ -103,27 +118,27 @@ export class ScormAdapterService { try { latestTest = JSON.parse(xhr.responseText); console.log('Latest test result:', latestTest); - this.testId = latestTest.data.id; + this.playerContext.attemptId = latestTest.data.id; if (latestTest.data['cmi_entry'] === 'ab-initio') { - console.log("starting new test"); - this.SetValue('cmi.learner_id', this.learnerId); - this.SetValue('cmi.learner_name', this.learnerName); - this.dataStore['name'] = examName; - this.dataStore['attempt_number'] = latestTest.data['attempt_number']; - console.log(this.dataStore); - } else if (latestTest.data['cmi_entry'] === 'resume') { - console.log("resuming test"); - let parsedSuspendData = JSON.parse(latestTest.data.suspend_data ?? '{}'); + console.log('starting new test'); + this.dataModel.init(); + this.SetValue('cmi.learner_id', this.playerContext.learnerId); + this.SetValue('cmi.learner_name', this.playerContext.learnerName); - this.dataStore = JSON.parse(JSON.stringify(parsedSuspendData)); + this.dataModel.set('attempt_number', latestTest.data['attempt_number']); + console.log(this.dataModel.dump()); + } else if (latestTest.data['cmi_entry'] === 'resume') { + console.log('resuming test'); + const restoredDataModel = JSON.parse(latestTest.data.suspend_data ?? '{}'); + this.dataModel.restore(JSON.parse(JSON.stringify(restoredDataModel))); - console.log(this.dataStore); + console.log(this.dataModel.dump()); } this.initializationComplete$.next(true); - console.log("finished initializing"); + console.log('finished initializing'); return 'true'; } catch (error) { console.error('Error:', error); @@ -131,67 +146,56 @@ export class ScormAdapterService { } } - isTestCompleted(): boolean { - return this.dataStore?.['completed'] ?? false; - } - - private resetDataStore() { - this.dataStore = this.getDefaultDataStore(); - } + // isTestCompleted(): boolean { + // return this.dataModel.get('completed') ?? false; + // } Terminate(): string { console.log('Terminate Called'); - const examResult = this.dataStore["cmi.score.raw"]; - const status = this.GetValue("cmi.success_status"); - this.dataStore['completed'] = true; - const currentAttemptNumber = this.dataStore['attempt_number'] ?? 0; - const ExamName = this.dataStore['name']; - this.SetValue('cmi.entry', 'RO'); - const cmientry = this.GetValue('cmi.entry'); + const examResult = this.dataModel.get('cmi.score.raw'); + const status = this.dataModel.get('cmi.success_status'); + this.dataModel.set('completed', true); + const currentAttemptNumber = this.dataModel.get('attempt_number') ?? 0; + const ExamName = this.dataModel.get('name'); + this.dataModel.set('cmi.entry', 'RO'); + const cmientry = this.dataModel.get('cmi.entry'); const data = { name: ExamName, attempt_number: currentAttemptNumber, pass_status: status === 'passed', - suspend_data: JSON.stringify(this.dataStore), + suspend_data: JSON.stringify(this.dataModel.dump()), completed: true, exam_result: examResult, cmi_entry: cmientry, - task_id: this.task.id + task_id: this.playerContext.task.id }; const xhr = new XMLHttpRequest(); - if (this.testId) { - xhr.open("PUT", `${this.apiBaseUrl}/${this.testId}`, false); + if (this.playerContext.attemptId) { + xhr.open('PUT', `${this.apiBaseUrl}/${this.playerContext.attemptId}`, false); } else { - xhr.open("POST", this.apiBaseUrl, false); + xhr.open('POST', this.apiBaseUrl, false); } - xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); xhr.send(JSON.stringify(data)); if (xhr.status !== 200) { console.error('Error sending test data:', xhr.statusText); return 'false'; } - this.resetDataStore(); + this.dataModel.init(); return 'true'; } GetValue(element: string): string { - return this.dataStore[element] ?? ''; + const value = this.dataModel.get(element); + console.log(`GetValue:`, element, value); + return value; } SetValue(element: string, value: any): string { console.log(`SetValue:`, element, value); - this.dataStore[element] = value; - if (element.match('cmi.interactions.\\d+.id')) { - console.log('Incrementing cmi.interactions._count'); - this.dataStore['cmi.interactions._count']++; - } - if (element.match('cmi.objectives.\\d+.id')) { - console.log('Incrementing cmi.objectives._count'); - this.dataStore['cmi.objectives._count']++; - } - // console.log("dataStore after value set:", this.dataStore); + this.dataModel.set(element, value); return 'true'; } @@ -203,15 +207,15 @@ export class ScormAdapterService { } // Set cmi.entry to 'resume' before committing dataStore - this.dataStore['cmi.entry'] = 'resume'; - if (!this.isTestCompleted()) { - this.dataStore['cmi.exit'] = 'suspend'; - } - console.log("Committing DataModel:", this.dataStore); + this.dataModel.set('cmi.entry', 'resume'); + // if (!this.isTestCompleted()) { + // this.dataModel.set('cmi.exit', 'suspend'); + // } + console.log('Committing DataModel:', this.dataModel.dump()); // Use XHR to send the request const xhr = new XMLHttpRequest(); - xhr.open('PUT', `${this.apiBaseUrl}/${this.testId}/suspend`, true); + xhr.open('PUT', `${this.apiBaseUrl}/${this.playerContext.attemptId}/suspend`, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = () => { @@ -226,7 +230,7 @@ export class ScormAdapterService { console.error('Request failed.'); }; - const requestData = { suspend_data: this.dataStore }; + const requestData = { suspend_data: this.dataModel.dump() }; xhr.send(JSON.stringify(requestData)); return 'true'; } diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index 7ed98b3cf..428f6869b 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { Task } from 'src/app/api/models/task'; +import { Task, ScormDataModel, ScormPlayerContext } from 'src/app/api/models/doubtfire-model'; import { ScormAdapterService } from 'src/app/api/services/scorm-adapter.service'; import { AppInjector } from 'src/app/app-injector'; import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; @@ -16,6 +16,8 @@ declare global { styleUrls: ['./scorm-player.component.scss'], }) export class ScormPlayerComponent implements OnInit { + context: ScormPlayerContext; + task: Task; currentMode: 'attempt' | 'review' = 'attempt'; iframeSrc: SafeResourceUrl; From b0863c7cf9319862b224705b50a238d2fabc5ec9 Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Sun, 12 May 2024 08:43:22 +1000 Subject: [PATCH 43/84] refactor: rewrite test attempt code --- src/app/api/models/scorm-datamodel.ts | 24 +- src/app/api/models/scorm-player-context.ts | 69 +++- src/app/api/models/task-definition.ts | 14 +- src/app/api/models/task.ts | 2 +- src/app/api/services/scorm-adapter.service.ts | 350 +++++++++--------- src/app/api/services/task-comment.service.ts | 2 +- .../api/services/task-definition.service.ts | 10 +- .../scorm-player-modal.component.ts | 15 +- .../scorm-player/scorm-player.component.ts | 41 +- .../upload-submission-modal.coffee | 2 +- .../numbas-comment.component.ts | 6 +- .../task-comments-viewer.component.html | 2 +- .../task-comments-viewer.component.scss | 2 +- .../task-definition-numbas.component.html | 10 +- 14 files changed, 289 insertions(+), 260 deletions(-) diff --git a/src/app/api/models/scorm-datamodel.ts b/src/app/api/models/scorm-datamodel.ts index e9d105f3c..9454db78f 100644 --- a/src/app/api/models/scorm-datamodel.ts +++ b/src/app/api/models/scorm-datamodel.ts @@ -1,12 +1,4 @@ export class ScormDataModel { - initState: {[key: string]: string} = { - 'cmi.completion_status': 'not attempted', - 'cmi.entry': 'ab-initio', - 'cmi.objectives._count': '0', - 'cmi.interactions._count': '0', - 'cmi.mode': 'normal', - }; - dataModel: {[key: string]: any} = {}; readonly msgPrefix = 'SCORM DataModel: '; @@ -14,17 +6,13 @@ export class ScormDataModel { this.dataModel = {}; } - public init() { - console.log(this.msgPrefix + 'initializing DataModel with default values'); - this.dataModel = this.initState; - } - - public restore(dataModel: {[key: string]: any} = {}) { + public restore(dataModel: string) { console.log(this.msgPrefix + 'restoring DataModel with provided data'); - this.dataModel = dataModel; + this.dataModel = JSON.parse(dataModel); } public get(key: string): string { + // console.log(`SCORM DataModel: get ${key} ${this.dataModel[key]}`); return this.dataModel[key] ?? ''; } @@ -33,23 +21,27 @@ export class ScormDataModel { } public set(key: string, value: any): string { - console.log(this.msgPrefix + 'set: ', key, value); + // 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 crated 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 crated console.log('Incrementing cmi.objectives._count'); this.dataModel['cmi.objectives._count']++; } diff --git a/src/app/api/models/scorm-player-context.ts b/src/app/api/models/scorm-player-context.ts index 5f1c4bd3b..f3b3b8a13 100644 --- a/src/app/api/models/scorm-player-context.ts +++ b/src/app/api/models/scorm-player-context.ts @@ -1,9 +1,56 @@ -import { Task, User } from 'src/app/api/models/doubtfire-model'; +import {Task, 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 { - task: Task; mode: 'browse' | 'normal' | 'review'; + state: DataModelState; + + private _errorCode: number; + get errorCode() { + return this._errorCode; + } + set errorCode(value: number) { + this._errorCode = value; + } + + getErrorMessage(value: string): string { + return CMIErrorCodes[value]; + } + + task: Task; user: User; + attemptNumber: number; attemptId: number; learnerName: string; @@ -13,21 +60,7 @@ export class ScormPlayerContext { this.user = user; this.learnerId = user.id; this.learnerName = user.firstName + ' ' + user.lastName; - } - - public setTask(task: Task): void { - this.task = task; - } - - public setMode(mode: 'browse' | 'normal' | 'review'): void { - this.mode = mode; - } - - public setAttemptNumber(attemptNumber: number = 1): void { - this.attemptNumber = attemptNumber; - } - - public setAttemptId(attemptId: number): void { - this.attemptId = attemptId; + this.state = 'Uninitialized'; + this.errorCode = 0; } } diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index a669b2fe3..985b81013 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -31,10 +31,10 @@ export class TaskDefinition extends Entity { groupSet: GroupSet = null; hasTaskSheet: boolean; hasTaskResources: boolean; - hasEnabledNumbasTest: boolean; - hasNumbasData: boolean; - hasNumbasTimeDelay: boolean; - numbasAttemptLimit: number = 0; + scormEnabled: boolean; + hasScormData: boolean; + scormTimeDelayEnabled: boolean; + scormAttemptLimit: number = 0; hasTaskAssessmentResources: boolean; isGraded: boolean; maxQualityPts: number; @@ -158,7 +158,7 @@ export class TaskDefinition extends Entity { public getNumbasTestUrl(asAttachment: boolean = false) { const constants = AppInjector.get(DoubtfireConstants); - return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/numbas_data.json${ + return `${constants.API_URL}/units/${this.unit.id}/task_definitions/${this.id}/scorm_data.json${ asAttachment ? '?as_attachment=true' : '' }`; } @@ -190,7 +190,7 @@ export class TaskDefinition extends Entity { public get numbasTestUploadUrl(): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ this.id - }/numbas_data`; + }/scorm_data`; } public get taskAssessmentResourcesUploadUrl(): string { @@ -217,7 +217,7 @@ export class TaskDefinition extends Entity { public deleteNumbasTest(): Observable { const httpClient = AppInjector.get(HttpClient); - return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasNumbasData = false))); + return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasScormData = false))); } public deleteTaskAssessmentResources(): Observable { diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 403165cfe..3803d325b 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -509,7 +509,7 @@ export class Task extends Entity { public get numbasEnabled(): boolean { return ( - this.definition.hasEnabledNumbasTest && this.definition.hasNumbasData + this.definition.scormEnabled && this.definition.hasScormData ); } diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index 6c5353746..888612103 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -1,222 +1,212 @@ -import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; -import { UserService } from './user.service'; +import {Injectable} from '@angular/core'; +import {UserService} from './user.service'; import API_URL from 'src/app/config/constants/apiURL'; -import { Task, ScormDataModel, ScormPlayerContext } from 'src/app/api/models/doubtfire-model'; +import {Task, ScormDataModel, ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ScormAdapterService { private readonly apiBaseUrl = `${API_URL}/test_attempts`; private dataModel: ScormDataModel; - private playerContext: ScormPlayerContext; - - initializationComplete$ = new BehaviorSubject(false); - - private scormErrorCodes: {[key: string]: string} = { - '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', - }; + private context: ScormPlayerContext; + private xhr: XMLHttpRequest; constructor(private userService: UserService) { this.dataModel = new ScormDataModel(); - this.playerContext = new ScormPlayerContext(this.userService.currentUser); + this.context = new ScormPlayerContext(this.userService.currentUser); + this.xhr = new XMLHttpRequest(); } - setTask(task: Task) { - this.playerContext.setTask(task); + set task(task: Task) { + this.context.task = task; } - // getDefaultDataStore() { - // // Use spread operator to merge defaultValues into the dataStore - // return { - // ...this.defaultValues, - // pass_status: false, - // completed: false, - // }; - // } - - Initialize(mode: 'attempt' | 'review' = 'attempt'): string { - console.log('Initialize() function called'); - const xhr = new XMLHttpRequest(); - if (mode === 'review') { - this.SetValue('cmi.mode', 'review'); - - xhr.open( - 'GET', - `${this.apiBaseUrl}/completed-latest?task_id=${this.playerContext.task.id}`, - false, - ); - xhr.send(); - console.log(xhr.responseText); + get state() { + return this.context.state; + } - if (xhr.status !== 200) { - console.error('Error fetching latest completed test result:', xhr.statusText); - return 'false'; - } + destroy() { + this.dataModel = new ScormDataModel(); + this.context.state = 'Uninitialized'; + } - try { - const completedTest = JSON.parse(xhr.responseText); - const parsedDataModel = JSON.parse(completedTest.data.suspend_data ?? '{}'); + 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; + } - // Set entire suspendData string to cmi.suspend_data - this.SetValue('cmi.suspend_data', JSON.stringify(parsedDataModel)); + // TODO: move this part into the player component + this.xhr.open('GET', `${this.apiBaseUrl}/${this.context.task.id}/latest`, false); - // // Use SetValue to set parsedSuspendData values to dataStore - // Object.keys(parsedDataModel).forEach((key) => { - // this.SetValue(key, parsedDataModel[key]); - // }); + let noTestFound = false; + let startNewTest = false; - this.dataModel.restore(parsedDataModel); + this.xhr.onload = () => { + 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.SetValue('cmi.entry', 'RO'); - this.SetValue('cmi.mode', 'review'); + this.xhr.send(); + console.log(this.xhr.responseText); - console.log('Latest completed test data:', completedTest); - return 'true'; - } catch (error) { - console.error('Error:', error); - return 'false'; + 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; } - xhr.open('GET', `${this.apiBaseUrl}/latest?task_id=${this.playerContext.task.id}`, false); - xhr.send(); - console.log(xhr.responseText); - - if (xhr.status !== 200) { - console.error('Error fetching latest test result:', xhr.statusText); - return 'false'; + if (!startNewTest) { + this.xhr.open( + 'PATCH', + `${this.apiBaseUrl}/${this.context.task.id}/session/${this.context.attemptId}`, + false, + ); + 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', `${this.apiBaseUrl}/${this.context.task.id}/session`, false); + 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()); } - let latestTest; - try { - latestTest = JSON.parse(xhr.responseText); - console.log('Latest test result:', latestTest); - this.playerContext.attemptId = latestTest.data.id; - - if (latestTest.data['cmi_entry'] === 'ab-initio') { - console.log('starting new test'); - this.dataModel.init(); - this.SetValue('cmi.learner_id', this.playerContext.learnerId); - this.SetValue('cmi.learner_name', this.playerContext.learnerName); - - this.dataModel.set('attempt_number', latestTest.data['attempt_number']); - console.log(this.dataModel.dump()); - } else if (latestTest.data['cmi_entry'] === 'resume') { - console.log('resuming test'); - const restoredDataModel = JSON.parse(latestTest.data.suspend_data ?? '{}'); - this.dataModel.restore(JSON.parse(JSON.stringify(restoredDataModel))); - - console.log(this.dataModel.dump()); - } - - this.initializationComplete$.next(true); - - console.log('finished initializing'); - return 'true'; - } catch (error) { - console.error('Error:', error); - return 'false'; - } + this.context.state = 'Initialized'; + return 'true'; } - // isTestCompleted(): boolean { - // return this.dataModel.get('completed') ?? false; - // } - Terminate(): string { - console.log('Terminate Called'); - const examResult = this.dataModel.get('cmi.score.raw'); - const status = this.dataModel.get('cmi.success_status'); - this.dataModel.set('completed', true); - const currentAttemptNumber = this.dataModel.get('attempt_number') ?? 0; - const ExamName = this.dataModel.get('name'); - this.dataModel.set('cmi.entry', 'RO'); - const cmientry = this.dataModel.get('cmi.entry'); - const data = { - name: ExamName, - attempt_number: currentAttemptNumber, - pass_status: status === 'passed', - suspend_data: JSON.stringify(this.dataModel.dump()), - completed: true, - exam_result: examResult, - cmi_entry: cmientry, - task_id: this.playerContext.task.id - }; - - const xhr = new XMLHttpRequest(); - if (this.playerContext.attemptId) { - xhr.open('PUT', `${this.apiBaseUrl}/${this.playerContext.attemptId}`, false); - } else { - xhr.open('POST', this.apiBaseUrl, false); + 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; } - xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); - xhr.send(JSON.stringify(data)); - if (xhr.status !== 200) { - console.error('Error sending test data:', xhr.statusText); - return 'false'; - } - this.dataModel.init(); + this.xhr.open( + 'PATCH', + `${this.apiBaseUrl}/${this.context.task.id}/session/${this.context.attemptId}`, + false, + ); + this.xhr.setRequestHeader('Content-Type', 'application/json'); + const requestData = { + 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); - console.log(`GetValue:`, element, value); + + // 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(`SetValue:`, element, value); + 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'; } - // Saves the state of the exam. Commit(): string { - if (!this.initializationComplete$.getValue()) { - console.warn('Initialization not complete. Cannot commit.'); - return 'false'; + 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; } - // Set cmi.entry to 'resume' before committing dataStore - this.dataModel.set('cmi.entry', 'resume'); - // if (!this.isTestCompleted()) { - // this.dataModel.set('cmi.exit', 'suspend'); - // } - console.log('Committing DataModel:', this.dataModel.dump()); - - // Use XHR to send the request const xhr = new XMLHttpRequest(); - xhr.open('PUT', `${this.apiBaseUrl}/${this.playerContext.attemptId}/suspend`, true); + xhr.open( + 'PATCH', + `${this.apiBaseUrl}/${this.context.task.id}/session/${this.context.attemptId}`, + true, + ); xhr.setRequestHeader('Content-Type', 'application/json'); + const requestData = { + cmi_datamodel: JSON.stringify(this.dataModel.dump()), + }; + xhr.send(JSON.stringify(requestData)); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 400) { @@ -230,23 +220,27 @@ export class ScormAdapterService { console.error('Request failed.'); }; - const requestData = { suspend_data: this.dataModel.dump() }; - xhr.send(JSON.stringify(requestData)); + this.context.errorCode = 0; return 'true'; } - // Placeholder methods for SCORM error handling GetLastError(): string { - //console.log('Get Last Error called'); - return "0"; + const lastError = this.context.errorCode.toString(); + if (lastError !== '0') { + console.log(`API_1484_11: GetLastError: ${lastError}`); + } + return lastError; } GetErrorString(errorCode: string): string { - return ''; + const errorString = this.context.getErrorMessage(errorCode); + console.log(`API_1484_11: GetErrorString:`, errorCode, errorString); + return errorString; } GetDiagnostic(errorCode: string): string { - //console.log('Get Diagnoistic called'); - return ''; + // 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 e3c80797a..5f034b870 100644 --- a/src/app/api/services/task-comment.service.ts +++ b/src/app/api/services/task-comment.service.ts @@ -145,7 +145,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' || commentType === 'numbas') { + if (commentType === 'text' || commentType === 'scorm') { body.append('comment', data); } else if (commentType === 'discussion') { opts.endpointFormat = this.discussionEndpointFormat; diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index 6047af054..a73862688 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -93,10 +93,10 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskSheet', 'hasTaskResources', 'hasTaskAssessmentResources', - 'hasEnabledNumbasTest', - 'hasNumbasData', - 'hasNumbasTimeDelay', - 'numbasAttemptLimit', + 'scormEnabled', + 'hasScormData', + 'scormTimeDelayEnabled', + 'scormAttemptLimit', 'isGraded', 'maxQualityPts', 'overseerImageId', @@ -108,7 +108,7 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskSheet', 'hasTaskResources', 'hasTaskAssessmentResources', - 'hasNumbasData' + 'hasScormData' ); } diff --git a/src/app/common/scorm-player/scorm-player-modal.component.ts b/src/app/common/scorm-player/scorm-player-modal.component.ts index 190e7bf57..2fb463026 100644 --- a/src/app/common/scorm-player/scorm-player-modal.component.ts +++ b/src/app/common/scorm-player/scorm-player-modal.component.ts @@ -1,20 +1,21 @@ -import { Injectable } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { ScormPlayerComponent } from './scorm-player.component'; -import { Task } from 'src/app/api/models/task'; +import {Injectable} from '@angular/core'; +import {MatDialog, MatDialogRef} from '@angular/material/dialog'; +import {ScormPlayerComponent} from './scorm-player.component'; +import {Task} from 'src/app/api/models/task'; @Injectable({ providedIn: 'root', }) export class ScormPlayerModal { - constructor(public dialog: MatDialog) { } + constructor(public dialog: MatDialog) {} public show(task: Task, mode: 'attempt' | 'review'): void { let dialogRef: MatDialogRef; dialogRef = this.dialog.open(ScormPlayerComponent, { - data: { task, mode }, - width: '95%', height: '90%', + data: {task, mode}, + width: '95%', + height: '90%', disableClose: true, }); } diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index 428f6869b..1b89e36ba 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -1,13 +1,15 @@ -import { Component, OnInit, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { Task, ScormDataModel, ScormPlayerContext } from 'src/app/api/models/doubtfire-model'; -import { ScormAdapterService } from 'src/app/api/services/scorm-adapter.service'; -import { AppInjector } from 'src/app/app-injector'; -import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; +import {Component, OnInit, Inject} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; +import {Task, ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; +import {ScormAdapterService} from 'src/app/api/services/scorm-adapter.service'; +import {AppInjector} from 'src/app/app-injector'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; declare global { - interface Window { API_1484_11: any; } + interface Window { + API_1484_11: any; + } } @Component({ @@ -24,34 +26,41 @@ export class ScormPlayerComponent implements OnInit { constructor( private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { task: Task, mode: 'attempt' | 'review' }, + @Inject(MAT_DIALOG_DATA) public data: {task: Task; mode: 'attempt' | 'review'}, private scormAdapter: ScormAdapterService, - private sanitizer: DomSanitizer + private sanitizer: DomSanitizer, ) {} ngOnInit(): void { this.task = this.data.task; - this.scormAdapter.setTask(this.task); + this.scormAdapter.task = this.task; window.API_1484_11 = { - Initialize: () => this.scormAdapter.Initialize(this.currentMode), + Initialize: () => this.scormAdapter.Initialize(), Terminate: () => this.scormAdapter.Terminate(), GetValue: (element: string) => this.scormAdapter.GetValue(element), SetValue: (element: string, value: string) => this.scormAdapter.SetValue(element, value), Commit: () => this.scormAdapter.Commit(), GetLastError: () => this.scormAdapter.GetLastError(), GetErrorString: (errorCode: string) => this.scormAdapter.GetErrorString(errorCode), - GetDiagnostic: (errorCode: string) => this.scormAdapter.GetDiagnostic(errorCode) + GetDiagnostic: (errorCode: string) => this.scormAdapter.GetDiagnostic(errorCode), }; this.currentMode = this.data.mode; - this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(`${AppInjector.get(DoubtfireConstants).API_URL}/numbas_api/${this.task.taskDefId}/index.html`); + this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl( + `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.task.taskDefId}/index.html`, + ); } close(): void { - console.log('SCORM player closing, commiting DataModel!'); - this.scormAdapter.Commit(); + if (this.scormAdapter.state == 'Initialized') { + console.log('SCORM player closing during an initialized session, commiting DataModel'); + this.scormAdapter.Commit(); + } + // TODO: would be nice if we can destroy this entire adapter object when the modal is closed + console.log('Clearing player context and DataModel'); + this.scormAdapter.destroy(); const iframe = document.getElementsByTagName('iframe')[0]; iframe?.parentNode?.removeChild(iframe); this.dialogRef.close(); diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index 6a17bd558..7c0baf7ee 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -128,7 +128,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) removed.push('group') if !isRFF || !task.isGroupTask() removed.push('alignment') if !isRFF || !task.unit.ilos.length > 0 removed.push('comments') if isTestSubmission - removed.push('scorm-assessment') if !isRFF || !task.definition.hasEnabledNumbasTest + removed.push('scorm-assessment') if !isRFF || !task.definition.scormEnabled removed # Initialises the states initialise: -> diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts index 650a110d0..c8ba676c5 100644 --- a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit, Input } from '@angular/core'; -import { Task, TaskComment } from 'src/app/api/models/doubtfire-model'; -import { ScormPlayerModal } from 'src/app/common/scorm-player/scorm-player-modal.component'; +import {Component, OnInit, Input} from '@angular/core'; +import {Task, TaskComment} from 'src/app/api/models/doubtfire-model'; +import {ScormPlayerModal} from 'src/app/common/scorm-player/scorm-player-modal.component'; @Component({ selector: 'numbas-comment', diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index cd18eda9b..0be0e7b75 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -72,7 +72,7 @@ >
-
+
- + Enable Numbas Test @@ -10,7 +10,7 @@ accept="application/zip" [desiredFileName]="'Numbas zip'" /> - @if (taskDefinition.hasNumbasData) { + @if (taskDefinition.hasScormData) {
- @if (taskDefinition.hasEnabledNumbasTest) { + @if (taskDefinition.scormEnabled) {
- + Enable incremental time delays between test attempts @@ -34,7 +34,7 @@ min="0" max="100" type="number" - [(ngModel)]="taskDefinition.numbasAttemptLimit" + [(ngModel)]="taskDefinition.scormAttemptLimit" [formControl]="attemptLimitControl" /> From 644c025e698480276a068d717589be547817ddc8 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 14 May 2024 13:18:33 +1000 Subject: [PATCH 44/84] refactor: rename numbas references to scorm and fix typos --- src/app/api/models/scorm-datamodel.ts | 4 +- src/app/api/models/task-definition.ts | 8 +- src/app/api/models/task.ts | 2 +- .../spec/scorm-adapter.service.spec.ts | 87 ------------------- .../api/services/task-definition.service.ts | 4 +- .../scorm-player-modal.component.ts | 2 +- .../scorm-player/scorm-player.component.ts | 4 +- src/app/doubtfire-angular.module.ts | 8 +- .../upload-submission-modal.coffee | 2 +- .../upload-submission-modal.tpl.html | 6 +- .../scorm-comment.component.html} | 4 +- .../scorm-comment.component.scss} | 0 .../scorm-comment.component.ts} | 10 +-- .../task-comments-viewer.component.html | 8 +- .../task-comments-viewer.component.ts | 6 +- .../task-definition-editor.component.html | 6 +- .../task-definition-scorm.component.html} | 14 +-- .../task-definition-scorm.component.scss} | 0 .../task-definition-scorm.component.ts} | 32 +++---- 19 files changed, 60 insertions(+), 147 deletions(-) delete mode 100644 src/app/api/services/spec/scorm-adapter.service.spec.ts rename src/app/tasks/task-comments-viewer/{numbas-comment/numbas-comment.component.html => scorm-comment/scorm-comment.component.html} (65%) rename src/app/tasks/task-comments-viewer/{numbas-comment/numbas-comment.component.scss => scorm-comment/scorm-comment.component.scss} (100%) rename src/app/tasks/task-comments-viewer/{numbas-comment/numbas-comment.component.ts => scorm-comment/scorm-comment.component.ts} (66%) rename src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/{task-definition-numbas/task-definition-numbas.component.html => task-definition-scorm/task-definition-scorm.component.html} (78%) rename src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/{task-definition-numbas/task-definition-numbas.component.scss => task-definition-scorm/task-definition-scorm.component.scss} (100%) rename src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/{task-definition-numbas/task-definition-numbas.component.ts => task-definition-scorm/task-definition-scorm.component.ts} (58%) diff --git a/src/app/api/models/scorm-datamodel.ts b/src/app/api/models/scorm-datamodel.ts index 9454db78f..7559318eb 100644 --- a/src/app/api/models/scorm-datamodel.ts +++ b/src/app/api/models/scorm-datamodel.ts @@ -24,7 +24,7 @@ export class ScormDataModel { // 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 crated + // 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'); @@ -41,7 +41,7 @@ export class ScormDataModel { this.dataModel[objectivesCounterForInteraction.toString()]++; } if (key.match('cmi.objectives.\\d+.id')) { - // cmi.objectives._count must be incremented after a new objective is crated + // cmi.objectives._count must be incremented after a new objective is created console.log('Incrementing cmi.objectives._count'); this.dataModel['cmi.objectives._count']++; } diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 985b81013..538b44933 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -156,7 +156,7 @@ export class TaskDefinition extends Entity { }`; } - public getNumbasTestUrl(asAttachment: boolean = false) { + 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' : '' @@ -187,7 +187,7 @@ export class TaskDefinition extends Entity { }/task_resources`; } - public get numbasTestUploadUrl(): string { + public get scormDataUploadUrl(): string { return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${ this.id }/scorm_data`; @@ -215,9 +215,9 @@ export class TaskDefinition extends Entity { return httpClient.delete(this.taskResourcesUploadUrl).pipe(tap(() => (this.hasTaskResources = false))); } - public deleteNumbasTest(): Observable { + public deleteScormData(): Observable { const httpClient = AppInjector.get(HttpClient); - return httpClient.delete(this.numbasTestUploadUrl).pipe(tap(() => (this.hasScormData = false))); + return httpClient.delete(this.scormDataUploadUrl).pipe(tap(() => (this.hasScormData = false))); } public deleteTaskAssessmentResources(): Observable { diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 3803d325b..556d0a7dd 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -507,7 +507,7 @@ export class Task extends Entity { ); } - public get numbasEnabled(): boolean { + public get scormEnabled(): boolean { return ( this.definition.scormEnabled && this.definition.hasScormData ); diff --git a/src/app/api/services/spec/scorm-adapter.service.spec.ts b/src/app/api/services/spec/scorm-adapter.service.spec.ts deleted file mode 100644 index 5d3a52caa..000000000 --- a/src/app/api/services/spec/scorm-adapter.service.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { ScormAdapterService } from '../scorm-adapter.service'; -import { TaskService } from '../task.service'; -import { UserService } from '../user.service'; - -describe('ScormAdapterService', () => { - let service: ScormAdapterService; - let httpTestingController: HttpTestingController; - let mockUserService: Partial; - let mockTaskService: Partial; - - const mockUserData = { - currentUser: { studentId: '12345' } - }; - - beforeEach(() => { - mockUserService = { - currentUser: mockUserData.currentUser - }; - - mockTaskService = { - // you can add mocked methods if needed for the TaskService - }; - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [ - ScormAdapterService, - { provide: UserService, useValue: mockUserService }, - { provide: TaskService, useValue: mockTaskService } - ] - }); - - service = TestBed.inject(ScormAdapterService); - httpTestingController = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpTestingController.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should initialize with default values', () => { - expect(service.GetValue('cmi.completion_status')).toBe('not attempted'); - expect(service.GetValue('cmi.entry')).toBe('ab-initio'); - }); - - describe('Initialize function', () => { - - it('should handle review mode and get latest completed test result', () => { - const mockResponse = { - data: { - suspend_data: JSON.stringify({ someData: 'value' }) - } - }; - - service.Initialize('review'); - const req = httpTestingController.expectOne(`${service['apiBaseUrl']}/completed-latest`); - expect(req.request.method).toEqual('GET'); - req.flush(mockResponse); - - expect(service.GetValue('cmi.suspend_data')).toEqual(JSON.stringify({ someData: 'value' })); - }); - - it('should handle attempt mode and get latest test result', () => { - const mockResponse = { - data: { - id: 1, - cmi_entry: 'ab-initio', - attempt_number: 2 - } - }; - - service.Initialize('attempt'); - const req = httpTestingController.expectOne(`${service['apiBaseUrl']}/latest`); - expect(req.request.method).toEqual('GET'); - req.flush(mockResponse); - - expect(service.GetValue('cmi.learner_id')).toBe('12345'); - }); - }); - -}); diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index a73862688..defd83dc7 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -134,9 +134,9 @@ export class TaskDefinitionService extends CachedEntityService { return AppInjector.get(HttpClient).post(taskDefinition.taskAssessmentResourcesUploadUrl, formData); } - public uploadNumbasData(taskDefinition: TaskDefinition, file: File): Observable { + public uploadScormData(taskDefinition: TaskDefinition, file: File): Observable { const formData = new FormData(); formData.append('file', file); - return AppInjector.get(HttpClient).post(taskDefinition.numbasTestUploadUrl, formData); + return AppInjector.get(HttpClient).post(taskDefinition.scormDataUploadUrl, formData); } } diff --git a/src/app/common/scorm-player/scorm-player-modal.component.ts b/src/app/common/scorm-player/scorm-player-modal.component.ts index 2fb463026..cd7740571 100644 --- a/src/app/common/scorm-player/scorm-player-modal.component.ts +++ b/src/app/common/scorm-player/scorm-player-modal.component.ts @@ -9,7 +9,7 @@ import {Task} from 'src/app/api/models/task'; export class ScormPlayerModal { constructor(public dialog: MatDialog) {} - public show(task: Task, mode: 'attempt' | 'review'): void { + public show(task: Task, mode: 'browse' | 'normal' | 'review'): void { let dialogRef: MatDialogRef; dialogRef = this.dialog.open(ScormPlayerComponent, { diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index 1b89e36ba..106e6a852 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -21,12 +21,12 @@ export class ScormPlayerComponent implements OnInit { context: ScormPlayerContext; task: Task; - currentMode: 'attempt' | 'review' = 'attempt'; + currentMode: 'browse' | 'normal' | 'review' = 'normal'; iframeSrc: SafeResourceUrl; constructor( private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: {task: Task; mode: 'attempt' | 'review'}, + @Inject(MAT_DIALOG_DATA) public data: {task: Task, mode: 'browse' | 'normal' | 'review'}, private scormAdapter: ScormAdapterService, private sanitizer: DomSanitizer, ) {} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 23145ccae..110ae4a3a 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -204,7 +204,7 @@ import {TaskDefinitionUploadComponent} from './units/states/edit/directives/unit import {TaskDefinitionOptionsComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-options/task-definition-options.component'; import {TaskDefinitionResourcesComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-resources/task-definition-resources.component'; import {TaskDefinitionOverseerComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-overseer/task-definition-overseer.component'; -import {TaskDefinitionNumbasComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component'; +import {TaskDefinitionScormComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component'; import {UnitAnalyticsComponent} from './units/states/analytics/unit-analytics-route.component'; import {FileDropComponent} from './common/file-drop/file-drop.component'; import {UnitTaskEditorComponent} from './units/states/edit/directives/unit-tasks-editor/unit-task-editor.component'; @@ -228,7 +228,7 @@ import {UnitCodeComponent} from './common/unit-code/unit-code.component'; import {GradeService} from './common/services/grade.service'; import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; import {ScormAdapterService} from './api/services/scorm-adapter.service'; -import {NumbasCommentComponent} from './tasks/task-comments-viewer/numbas-comment/numbas-comment.component'; +import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; import {TestAttemptService} from './api/services/test-attempt.service'; @NgModule({ @@ -268,7 +268,7 @@ import {TestAttemptService} from './api/services/test-attempt.service'; TaskDefinitionOptionsComponent, TaskDefinitionResourcesComponent, TaskDefinitionOverseerComponent, - TaskDefinitionNumbasComponent, + TaskDefinitionScormComponent, UnitAnalyticsComponent, StudentTutorialSelectComponent, StudentCampusSelectComponent, @@ -333,7 +333,7 @@ import {TestAttemptService} from './api/services/test-attempt.service'; FTaskBadgeComponent, FUnitsComponent, ScormPlayerComponent, - NumbasCommentComponent, + ScormCommentComponent, ], // Services we provide providers: [ diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index 7c0baf7ee..976c116f3 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -157,7 +157,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) $scope.launchScormPlayer = -> console.clear() - ScormPlayerModal.show $scope.task, 'attempt' + ScormPlayerModal.show $scope.task, 'normal' # Whether or not we should disable this button $scope.shouldDisableBtn = { diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index 8b6c89bea..0b296c209 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -50,15 +50,15 @@

- Attempt Numbas Test + Attempt SCORM Test

- Complete the Numbas test first to proceed to upload evidence of your task completion. + Complete the SCORM test first to proceed to upload evidence of your task completion.
diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html similarity index 65% rename from src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html rename to src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html index d87863bee..b74db43a0 100644 --- a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.html +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -1,9 +1,9 @@
-
+
- +
diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.scss b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss similarity index 100% rename from src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.scss rename to src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss diff --git a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts similarity index 66% rename from src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts rename to src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts index c8ba676c5..817a6cd64 100644 --- a/src/app/tasks/task-comments-viewer/numbas-comment/numbas-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -3,11 +3,11 @@ import {Task, TaskComment} from 'src/app/api/models/doubtfire-model'; import {ScormPlayerModal} from 'src/app/common/scorm-player/scorm-player-modal.component'; @Component({ - selector: 'numbas-comment', - templateUrl: './numbas-comment.component.html', - styleUrls: ['./numbas-comment.component.scss'], + selector: 'scorm-comment', + templateUrl: './scorm-comment.component.html', + styleUrls: ['./scorm-comment.component.scss'], }) -export class NumbasCommentComponent implements OnInit { +export class ScormCommentComponent implements OnInit { @Input() task: Task; @Input() comment: TaskComment; @@ -15,7 +15,7 @@ export class NumbasCommentComponent implements OnInit { ngOnInit() {} - reviewNumbasTest() { + reviewScormTest() { this.modalService.show(this.task, 'review'); } } diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index 0be0e7b75..aa20275ff 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -72,12 +72,12 @@ >
-
- + + *ngIf="scormEnabled" + >
diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts index 7436cd7d7..8ad719f33 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts @@ -98,8 +98,8 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit { return this.constants.IsOverseerEnabled.value; } - get numbasEnabled(): boolean { - return this.task.numbasEnabled; + get scormEnabled(): boolean { + return this.task.scormEnabled; } uploadFiles(event) { @@ -154,7 +154,7 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit { } shouldShowAuthorIcon(commentType: string) { - return !(commentType === 'extension' || commentType === 'status' || commentType == 'assessment' || commentType == 'numbas'); + return !(commentType === 'extension' || commentType === 'status' || commentType == 'assessment' || commentType == 'scorm'); } commentClasses(comment: TaskComment): object { diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index bd2c7ec65..6bc386569 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -105,10 +105,10 @@

-

Upload Numbas test

-

Upload the corresponding Numbas test

+

Upload SCORM test

+

Upload the corresponding SCORM 2004 test (e.g. Numbas)

- +

diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html similarity index 78% rename from src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html rename to src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html index 36acb4ca4..433405669 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.html @@ -1,22 +1,22 @@
- Enable Numbas Test + Enable test for task
@if (taskDefinition.hasScormData) {
- -
} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.scss similarity index 100% rename from src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.scss rename to src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.scss diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts similarity index 58% rename from src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts rename to src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts index f6687a4af..8f2b72fcb 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-numbas/task-definition-numbas.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts @@ -7,11 +7,11 @@ import { TaskDefinitionService } from 'src/app/api/services/task-definition.serv import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; @Component({ - selector: 'f-task-definition-numbas', - templateUrl: 'task-definition-numbas.component.html', - styleUrls: ['task-definition-numbas.component.scss'], + selector: 'f-task-definition-scorm', + templateUrl: 'task-definition-scorm.component.html', + styleUrls: ['task-definition-scorm.component.scss'], }) -export class TaskDefinitionNumbasComponent { +export class TaskDefinitionScormComponent { @Input() taskDefinition: TaskDefinition; constructor( @@ -26,32 +26,32 @@ export class TaskDefinitionNumbasComponent { return this.taskDefinition?.unit; } - public downloadNumbasTest() { + public downloadScormData() { this.fileDownloaderService.downloadFile( - this.taskDefinition.getNumbasTestUrl(true), - this.taskDefinition.name + '-Numbas.zip', + this.taskDefinition.getScormDataUrl(true), + this.taskDefinition.name + '-SCORM.zip', ); } - public removeNumbasTest() { - this.taskDefinition.deleteNumbasTest().subscribe({ - next: () => this.alerts.add('success', 'Deleted Numbas test', 2000), - error: (message) => this.alerts.add('danger', message, 6000), + public removeScormData() { + this.taskDefinition.deleteScormData().subscribe({ + next: () => this.alerts.success('Deleted SCORM test data', 2000), + error: (message) => this.alerts.error(message, 6000), }); } - public uploadNumbasTest(files: FileList) { + public uploadScormData(files: FileList) { console.log(Array.from(files).map(f => f.type)); const validMimeTypes = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']; const validFiles = Array.from(files as ArrayLike).filter(f => validMimeTypes.includes(f.type)); if (validFiles.length > 0) { const file = validFiles[0]; - this.taskDefinitionService.uploadNumbasData(this.taskDefinition, file).subscribe({ - next: () => this.alerts.add('success', 'Uploaded Numbas test data', 2000), - error: (message) => this.alerts.add('danger', message, 6000), + this.taskDefinitionService.uploadScormData(this.taskDefinition, file).subscribe({ + next: () => this.alerts.success('Uploaded SCORM test data', 2000), + error: (message) => this.alerts.error(message, 6000), }); } else { - this.alerts.add('danger', 'Please drop a zip file to upload Numbas test data for this task', 6000); + this.alerts.error('Please drop a zip file to upload SCORM test data for this task', 6000); } } } From 2ac487f13c3743f2b5b805521964bdcbca7cdc15 Mon Sep 17 00:00:00 2001 From: ublefo <90136978+ublefo@users.noreply.github.com> Date: Tue, 14 May 2024 13:25:23 +1000 Subject: [PATCH 45/84] fix: ensure datamodel is updated on termination --- src/app/api/services/scorm-adapter.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index 888612103..9519f4cc2 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -129,6 +129,7 @@ export class ScormAdapterService { ); this.xhr.setRequestHeader('Content-Type', 'application/json'); const requestData = { + cmi_datamodel: JSON.stringify(this.dataModel.dump()), terminated: true, }; this.xhr.send(JSON.stringify(requestData)); From fc023af462656e3557e2970f211fa4d59ee1e3d5 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 14 May 2024 14:24:31 +1000 Subject: [PATCH 46/84] feat: allow changing scorm review config and add minor UI changes --- src/app/api/models/task-definition.ts | 1 + .../api/services/task-definition.service.ts | 1 + .../task-definition-editor.component.html | 2 +- .../task-definition-scorm.component.html | 49 ++++++++++--------- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index 538b44933..ca2d188eb 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -33,6 +33,7 @@ export class TaskDefinition extends Entity { hasTaskResources: boolean; scormEnabled: boolean; hasScormData: boolean; + scormAllowReview: boolean; scormTimeDelayEnabled: boolean; scormAttemptLimit: number = 0; hasTaskAssessmentResources: boolean; diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index defd83dc7..3776432fd 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -95,6 +95,7 @@ export class TaskDefinitionService extends CachedEntityService { 'hasTaskAssessmentResources', 'scormEnabled', 'hasScormData', + 'scormAllowReview', 'scormTimeDelayEnabled', 'scormAttemptLimit', 'isGraded', diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index 6bc386569..bf75eda5b 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -105,7 +105,7 @@

-

Upload SCORM test

+

SCORM test

Upload the corresponding SCORM 2004 test (e.g. Numbas)

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 index 433405669..5b3b54c11 100644 --- 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 @@ -3,30 +3,35 @@ Enable test for task -
- - @if (taskDefinition.hasScormData) { -
- - -
- } -
- @if (taskDefinition.scormEnabled) { +
+ + @if (taskDefinition.hasScormData) { +
+ + +
+ } +
+
- - Enable incremental time delays between test attempts - +
+ + Enable incremental time delays between test attempts + + + Allow students to review completed test attempt + +
Attempt limit Date: Tue, 14 May 2024 14:29:48 +1000 Subject: [PATCH 47/84] refactor: remove test attempt model and service --- src/app/api/models/test-attempt.ts | 22 ----------- src/app/api/services/test-attempt.service.ts | 40 -------------------- src/app/doubtfire-angular.module.ts | 2 - src/app/doubtfire-angularjs.module.ts | 2 - 4 files changed, 66 deletions(-) delete mode 100644 src/app/api/models/test-attempt.ts delete mode 100644 src/app/api/services/test-attempt.service.ts diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts deleted file mode 100644 index e6f2e5d61..000000000 --- a/src/app/api/models/test-attempt.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Entity } from "ngx-entity-service"; -import { Task } from "./task"; - -export class TestAttempt extends Entity { - public id: number; - name: string; - attemptNumber: number; - passStatus: boolean; - suspendData: string; - completed: boolean; - cmiEntry: string; - examResult: string; - attemptedAt: Date; - taskId: number; - - task: Task; - - constructor(task: Task) { - super(); - this.task = task; - } -} diff --git a/src/app/api/services/test-attempt.service.ts b/src/app/api/services/test-attempt.service.ts deleted file mode 100644 index 094f2baa0..000000000 --- a/src/app/api/services/test-attempt.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from "@angular/core"; -import { EntityService } from "ngx-entity-service"; -import { TestAttempt } from "../models/test-attempt"; -import { HttpClient } from "@angular/common/http"; -import API_URL from "src/app/config/constants/apiURL"; -import { Task } from "../models/task"; -import { Observable } from "rxjs"; -import { AppInjector } from "src/app/app-injector"; -import { DoubtfireConstants } from "src/app/config/constants/doubtfire-constants"; - -@Injectable() -export class TestAttemptService extends EntityService { - protected readonly endpointFormat = '/test_attempts?id=:id:'; - - constructor(httpClient: HttpClient) { - super(httpClient, API_URL); - - this.mapping.addKeys( - 'id', - 'name', - 'attemptNumber', - 'passStatus', - 'suspendData', - 'completed', - 'cmiEntry', - 'examResult', - 'attemptedAt', - 'taskId' - ); - } - - public createInstanceFrom(json: object, other?: any): TestAttempt { - return new TestAttempt(other as Task); - } - - public getLatestCompletedTestAttempt(task: Task): Observable { - const url = `${AppInjector.get(DoubtfireConstants).API_URL}/test_attempts/completed-latest?task_id=${task.id}`; - return AppInjector.get(HttpClient).get(url); - } -} \ No newline at end of file diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 110ae4a3a..f464c6c13 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -229,7 +229,6 @@ import {GradeService} from './common/services/grade.service'; import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; import {ScormAdapterService} from './api/services/scorm-adapter.service'; import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; -import {TestAttemptService} from './api/services/test-attempt.service'; @NgModule({ // Components we declare @@ -407,7 +406,6 @@ import {TestAttemptService} from './api/services/test-attempt.service'; CreateNewUnitModal, ScormPlayerModalProvider, ScormAdapterService, - TestAttemptService, provideLottieOptions({ player: () => player, }), diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 3cd6555bd..eb119b464 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -227,7 +227,6 @@ import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; import {ScormPlayerModal} from './common/scorm-player/scorm-player-modal.component'; -import {TestAttemptService} from './api/services/test-attempt.service'; export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', @@ -311,7 +310,6 @@ DoubtfireAngularJSModule.factory( ); DoubtfireAngularJSModule.factory('CreateNewUnitModal', downgradeInjectable(CreateNewUnitModal)); DoubtfireAngularJSModule.factory('ScormPlayerModal', downgradeInjectable(ScormPlayerModal)); -DoubtfireAngularJSModule.factory('testAttemptService', downgradeInjectable(TestAttemptService)); // directive -> component DoubtfireAngularJSModule.directive( From ce53396ab93a98b30a78d9259849262bcec5e9ff Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Wed, 15 May 2024 17:39:20 +1000 Subject: [PATCH 48/84] refactor: use task card and new tab for scorm and match comment display --- src/app/ajs-upgraded-providers.ts | 7 --- src/app/api/models/scorm-player-context.ts | 4 +- src/app/api/services/scorm-adapter.service.ts | 20 +++++---- .../scorm-player-modal.component.ts | 22 ---------- .../scorm-player/scorm-player.component.html | 5 +-- .../scorm-player/scorm-player.component.scss | 12 ++---- .../scorm-player/scorm-player.component.ts | 43 +++++++++++-------- src/app/doubtfire-angular.module.ts | 4 +- src/app/doubtfire-angularjs.module.ts | 13 +++--- src/app/doubtfire.states.ts | 30 +++++++++++++ .../task-scorm-card.component.html | 27 ++++++++++++ .../task-scorm-card.component.scss | 0 .../task-scorm-card.component.ts | 31 +++++++++++++ .../task-dashboard/task-dashboard.tpl.html | 1 + .../upload-submission-modal.coffee | 9 +--- .../upload-submission-modal.tpl.html | 20 --------- .../scorm-comment.component.html | 5 ++- .../scorm-comment/scorm-comment.component.ts | 5 +-- 18 files changed, 147 insertions(+), 111 deletions(-) delete mode 100644 src/app/common/scorm-player/scorm-player-modal.component.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.scss create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts diff --git a/src/app/ajs-upgraded-providers.ts b/src/app/ajs-upgraded-providers.ts index f114da1c7..795869225 100644 --- a/src/app/ajs-upgraded-providers.ts +++ b/src/app/ajs-upgraded-providers.ts @@ -18,7 +18,6 @@ export const rootScope = new InjectionToken('$rootScope'); export const calendarModal = new InjectionToken('CalendarModal'); export const aboutDoubtfireModal = new InjectionToken('AboutDoubtfireModal'); export const plagiarismReportModal = new InjectionToken('PlagiarismReportModal'); -export const scormPlayerModal = new InjectionToken('ScormPlayerModal'); // Define a provider for the above injection token... // It will get the service from AngularJS via the factory @@ -117,9 +116,3 @@ export const UnitStudentEnrolmentModalProvider = { useFactory: (i) => i.get('UnitStudentEnrolmentModal'), deps: ['$injector'], }; - -export const ScormPlayerModalProvider = { - provide: scormPlayerModal, - useFactory: (i) => i.get('ScormPlayerModal'), - deps: ['$injector'], -}; diff --git a/src/app/api/models/scorm-player-context.ts b/src/app/api/models/scorm-player-context.ts index f3b3b8a13..ed7df07c0 100644 --- a/src/app/api/models/scorm-player-context.ts +++ b/src/app/api/models/scorm-player-context.ts @@ -1,4 +1,4 @@ -import {Task, User} from 'src/app/api/models/doubtfire-model'; +import {User} from 'src/app/api/models/doubtfire-model'; type DataModelState = 'Uninitialized' | 'Initialized' | 'Terminated'; @@ -48,7 +48,7 @@ export class ScormPlayerContext { return CMIErrorCodes[value]; } - task: Task; + taskId: number; user: User; attemptNumber: number; diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index 9519f4cc2..cb6a78095 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -1,7 +1,7 @@ import {Injectable} from '@angular/core'; import {UserService} from './user.service'; import API_URL from 'src/app/config/constants/apiURL'; -import {Task, ScormDataModel, ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; +import {ScormDataModel, ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; @Injectable({ providedIn: 'root', @@ -18,8 +18,12 @@ export class ScormAdapterService { this.xhr = new XMLHttpRequest(); } - set task(task: Task) { - this.context.task = task; + set taskId(taskId: number) { + this.context.taskId = taskId; + } + + set mode(mode: 'browse' | 'normal' | 'review') { + this.context.mode = mode; } get state() { @@ -47,7 +51,7 @@ export class ScormAdapterService { } // TODO: move this part into the player component - this.xhr.open('GET', `${this.apiBaseUrl}/${this.context.task.id}/latest`, false); + this.xhr.open('GET', `${this.apiBaseUrl}/${this.context.taskId}/latest`, false); let noTestFound = false; let startNewTest = false; @@ -80,7 +84,7 @@ export class ScormAdapterService { if (!startNewTest) { this.xhr.open( 'PATCH', - `${this.apiBaseUrl}/${this.context.task.id}/session/${this.context.attemptId}`, + `${this.apiBaseUrl}/${this.context.taskId}/session/${this.context.attemptId}`, false, ); this.xhr.send(); @@ -92,7 +96,7 @@ export class ScormAdapterService { this.dataModel.restore(currentSession.cmi_datamodel); console.log(this.dataModel.dump()); } else { - this.xhr.open('POST', `${this.apiBaseUrl}/${this.context.task.id}/session`, false); + this.xhr.open('POST', `${this.apiBaseUrl}/${this.context.taskId}/session`, false); this.xhr.send(); console.log(this.xhr.responseText); @@ -124,7 +128,7 @@ export class ScormAdapterService { this.xhr.open( 'PATCH', - `${this.apiBaseUrl}/${this.context.task.id}/session/${this.context.attemptId}`, + `${this.apiBaseUrl}/${this.context.taskId}/session/${this.context.attemptId}`, false, ); this.xhr.setRequestHeader('Content-Type', 'application/json'); @@ -200,7 +204,7 @@ export class ScormAdapterService { const xhr = new XMLHttpRequest(); xhr.open( 'PATCH', - `${this.apiBaseUrl}/${this.context.task.id}/session/${this.context.attemptId}`, + `${this.apiBaseUrl}/${this.context.taskId}/session/${this.context.attemptId}`, true, ); xhr.setRequestHeader('Content-Type', 'application/json'); diff --git a/src/app/common/scorm-player/scorm-player-modal.component.ts b/src/app/common/scorm-player/scorm-player-modal.component.ts deleted file mode 100644 index cd7740571..000000000 --- a/src/app/common/scorm-player/scorm-player-modal.component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {Injectable} from '@angular/core'; -import {MatDialog, MatDialogRef} from '@angular/material/dialog'; -import {ScormPlayerComponent} from './scorm-player.component'; -import {Task} from 'src/app/api/models/task'; - -@Injectable({ - providedIn: 'root', -}) -export class ScormPlayerModal { - constructor(public dialog: MatDialog) {} - - public show(task: Task, mode: 'browse' | 'normal' | 'review'): void { - let dialogRef: MatDialogRef; - - dialogRef = this.dialog.open(ScormPlayerComponent, { - data: {task, mode}, - width: '95%', - height: '90%', - disableClose: true, - }); - } -} diff --git a/src/app/common/scorm-player/scorm-player.component.html b/src/app/common/scorm-player/scorm-player.component.html index 5089ad9de..4855cf4d2 100644 --- a/src/app/common/scorm-player/scorm-player.component.html +++ b/src/app/common/scorm-player/scorm-player.component.html @@ -1,4 +1 @@ -
- - -
+ diff --git a/src/app/common/scorm-player/scorm-player.component.scss b/src/app/common/scorm-player/scorm-player.component.scss index 5e24cc5d9..f011d35ae 100644 --- a/src/app/common/scorm-player/scorm-player.component.scss +++ b/src/app/common/scorm-player/scorm-player.component.scss @@ -1,5 +1,7 @@ -.mat-dialog-content { +f-scorm-player { position: relative; + height: 100vh; + width: 100vw; } iframe { @@ -7,11 +9,5 @@ iframe { top: 0; left: 0; width: 100%; - height: 95%; + height: 100%; } - -button { - position: absolute; - bottom: 20px; - right: 20px; -} \ No newline at end of file diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index 106e6a852..e1c68dc87 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -1,10 +1,10 @@ -import {Component, OnInit, Inject} from '@angular/core'; -import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {Component, OnInit, Input, HostListener} from '@angular/core'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; -import {Task, ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; +import {ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; import {ScormAdapterService} from 'src/app/api/services/scorm-adapter.service'; import {AppInjector} from 'src/app/app-injector'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {GlobalStateService, ViewType} from 'src/app/projects/states/index/global-state.service'; declare global { interface Window { @@ -20,20 +20,29 @@ declare global { export class ScormPlayerComponent implements OnInit { context: ScormPlayerContext; - task: Task; - currentMode: 'browse' | 'normal' | 'review' = 'normal'; + @Input() + taskId: number; + + @Input() + taskDefId: number; + + @Input() + mode: 'browse' | 'normal' | 'review'; + iframeSrc: SafeResourceUrl; constructor( - private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: {task: Task, mode: 'browse' | 'normal' | 'review'}, + private globalState: GlobalStateService, private scormAdapter: ScormAdapterService, private sanitizer: DomSanitizer, ) {} ngOnInit(): void { - this.task = this.data.task; - this.scormAdapter.task = this.task; + this.globalState.setView(ViewType.OTHER); + this.globalState.hideHeader(); + + this.scormAdapter.taskId = this.taskId; + this.scormAdapter.mode = this.mode; window.API_1484_11 = { Initialize: () => this.scormAdapter.Initialize(), @@ -46,23 +55,21 @@ export class ScormPlayerComponent implements OnInit { GetDiagnostic: (errorCode: string) => this.scormAdapter.GetDiagnostic(errorCode), }; - this.currentMode = this.data.mode; - this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl( - `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.task.taskDefId}/index.html`, + `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.taskDefId}/index.html`, ); } - close(): void { + @HostListener('window:beforeunload', ['$event']) + beforeUnload($event: any): void { if (this.scormAdapter.state == 'Initialized') { console.log('SCORM player closing during an initialized session, commiting DataModel'); this.scormAdapter.Commit(); } - // TODO: would be nice if we can destroy this entire adapter object when the modal is closed - console.log('Clearing player context and DataModel'); + } + + @HostListener('window:unload', ['$event']) + onUnload($event: any): void { this.scormAdapter.destroy(); - const iframe = document.getElementsByTagName('iframe')[0]; - iframe?.parentNode?.removeChild(iframe); - this.dialogRef.close(); } } diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index f464c6c13..dabcd4914 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -72,7 +72,6 @@ import { gradeTaskModalProvider, uploadSubmissionModalProvider, ConfirmationModalProvider, - ScormPlayerModalProvider, } from './ajs-upgraded-providers'; import { TaskCommentComposerComponent, @@ -229,6 +228,7 @@ import {GradeService} from './common/services/grade.service'; import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; import {ScormAdapterService} from './api/services/scorm-adapter.service'; import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; +import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; @NgModule({ // Components we declare @@ -333,6 +333,7 @@ import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/ FUnitsComponent, ScormPlayerComponent, ScormCommentComponent, + TaskScormCardComponent, ], // Services we provide providers: [ @@ -404,7 +405,6 @@ import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/ TasksForInboxSearchPipe, IsActiveUnitRole, CreateNewUnitModal, - ScormPlayerModalProvider, ScormAdapterService, provideLottieOptions({ player: () => player, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index eb119b464..bc5ad5ed4 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -225,8 +225,7 @@ import {FUnitsComponent} from './admin/states/f-units/f-units.component'; import {MarkedPipe} from './common/pipes/marked.pipe'; import {AlertService} from './common/services/alert.service'; import {GradeService} from './common/services/grade.service'; -import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; -import {ScormPlayerModal} from './common/scorm-player/scorm-player-modal.component'; +import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', @@ -309,7 +308,6 @@ DoubtfireAngularJSModule.factory( downgradeInjectable(EditProfileDialogService), ); DoubtfireAngularJSModule.factory('CreateNewUnitModal', downgradeInjectable(CreateNewUnitModal)); -DoubtfireAngularJSModule.factory('ScormPlayerModal', downgradeInjectable(ScormPlayerModal)); // directive -> component DoubtfireAngularJSModule.directive( @@ -367,6 +365,10 @@ DoubtfireAngularJSModule.directive( 'activityTypeList', downgradeComponent({component: ActivityTypeListComponent}), ); +DoubtfireAngularJSModule.directive( + 'fTaskScormCard', + downgradeComponent({component: TaskScormCardComponent}), +); DoubtfireAngularJSModule.directive( 'fTaskStatusCard', downgradeComponent({component: TaskStatusCardComponent}), @@ -444,11 +446,6 @@ DoubtfireAngularJSModule.directive( ); DoubtfireAngularJSModule.directive('fUnits', downgradeComponent({component: FUnitsComponent})); -DoubtfireAngularJSModule.directive( - 'fScormPlayerComponent', - downgradeComponent({component: ScormPlayerComponent}), -); - // Global configuration DoubtfireAngularJSModule.directive( 'taskCommentsViewer', diff --git a/src/app/doubtfire.states.ts b/src/app/doubtfire.states.ts index b9f95e88a..23b60886d 100644 --- a/src/app/doubtfire.states.ts +++ b/src/app/doubtfire.states.ts @@ -8,6 +8,7 @@ import {TeachingPeriodListComponent} from './admin/states/teaching-periods/teach import {AcceptEulaComponent} from './eula/accept-eula/accept-eula.component'; import {FUsersComponent} from './admin/states/f-users/f-users.component'; import {FUnitsComponent} from './admin/states/f-units/f-units.component'; +import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component'; /* * Use this file to store any states that are sourced by angular components. @@ -291,6 +292,34 @@ const ViewAllUnits: NgHybridStateDeclaration = { }, }; +/** + * Define the SCORM Player state. + */ +const ScormPlayerState: NgHybridStateDeclaration = { + name: 'scorm-player', + url: '/task_def/:task_def_id/task/:task_id/scorm-player/:mode', + resolve: { + taskId: function ($stateParams) { + return $stateParams.task_id; + }, + taskDefId: function ($stateParams) { + return $stateParams.task_def_id; + }, + mode: function ($stateParams) { + return $stateParams.mode; + }, + }, + views: { + main: { + component: ScormPlayerComponent, + }, + }, + data: { + pageTitle: 'Knowledge Check', + roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin'], + }, +}; + /** * Export the list of states we have created in angular */ @@ -306,4 +335,5 @@ export const doubtfireStates = [ ViewAllProjectsState, ViewAllUnits, AdministerUnits, + ScormPlayerState, ]; diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html new file mode 100644 index 000000000..67265461c --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -0,0 +1,27 @@ + + + Knowledge Check + + +

+ You have to successfully pass this knowledge check to complete the task. +

+

+ You have {{ (task.definition.scormAttemptLimit > 0) ? task.definition.scormAttemptLimit : 'unlimited' }} attempts to complete this test. +

+

+ There will be an increased time delay between test attempts. First 2 attempts will not have a time delay in between. +

+
+ + + + +
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts new file mode 100644 index 000000000..7d7dce6e1 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -0,0 +1,31 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {Task} from 'src/app/api/models/task'; +import {TaskService} from 'src/app/api/services/task.service'; + +@Component({ + selector: 'f-task-scorm-card', + templateUrl: './task-scorm-card.component.html', + styleUrls: ['./task-scorm-card.component.scss'], +}) +export class TaskScormCardComponent implements OnInit { + @Input() task: Task; + attemptsLeft: number; + + constructor( + private taskService: TaskService, + ) {} + + ngOnInit(): void { + if (this.task) { + + } + } + + launchScormPlayer(): void { + window.open(`#/task_def/${this.task.taskDefId}/task/${this.task.id}/scorm-player/normal`, '_blank'); + } + + requestMoreAttempts(): void { + + } +} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html index 0962ddd82..8402f4252 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.tpl.html @@ -42,6 +42,7 @@
+ diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee index 976c116f3..b9c9ebd2b 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.coffee @@ -32,7 +32,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) UploadSubmissionModal ) -.controller('UploadSubmissionModalCtrl', ($scope, $rootScope, $timeout, $modalInstance, ScormPlayerModal, newTaskService, newProjectService, task, reuploadEvidence, outcomeService, PrivacyPolicy) -> +.controller('UploadSubmissionModalCtrl', ($scope, $rootScope, $timeout, $modalInstance, newTaskService, newProjectService, task, reuploadEvidence, outcomeService, PrivacyPolicy) -> $scope.privacyPolicy = PrivacyPolicy # Expose task to scope $scope.task = task @@ -100,7 +100,7 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) # States functionality states = { # All possible states - all: ['group', 'scorm-assessment', 'files', 'alignment', 'comments', 'uploading'] + all: ['group', 'files', 'alignment', 'comments', 'uploading'] # Only states which are shown (populated in initialise) shown: [] # The currently active state (set in initialise) @@ -128,7 +128,6 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) removed.push('group') if !isRFF || !task.isGroupTask() removed.push('alignment') if !isRFF || !task.unit.ilos.length > 0 removed.push('comments') if isTestSubmission - removed.push('scorm-assessment') if !isRFF || !task.definition.scormEnabled removed # Initialises the states initialise: -> @@ -155,10 +154,6 @@ angular.module('doubtfire.tasks.modals.upload-submission-modal', []) previous: states.previous } - $scope.launchScormPlayer = -> - console.clear() - ScormPlayerModal.show $scope.task, 'normal' - # Whether or not we should disable this button $scope.shouldDisableBtn = { next: -> diff --git a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html index 0b296c209..9caab7897 100644 --- a/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html +++ b/src/app/tasks/modals/upload-submission-modal/upload-submission-modal.tpl.html @@ -43,26 +43,6 @@

-
-
-
-

- Attempt SCORM Test -

- - Complete the SCORM test first to proceed to upload evidence of your task completion. - -
-
- -
-
-
diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html index b74db43a0..ffe375f19 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -1,9 +1,10 @@
-
+
- + +

diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts index 817a6cd64..b58462966 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -1,6 +1,5 @@ import {Component, OnInit, Input} from '@angular/core'; import {Task, TaskComment} from 'src/app/api/models/doubtfire-model'; -import {ScormPlayerModal} from 'src/app/common/scorm-player/scorm-player-modal.component'; @Component({ selector: 'scorm-comment', @@ -11,11 +10,11 @@ export class ScormCommentComponent implements OnInit { @Input() task: Task; @Input() comment: TaskComment; - constructor(private modalService: ScormPlayerModal) {} + constructor() {} ngOnInit() {} reviewScormTest() { - this.modalService.show(this.task, 'review'); + window.open(`#/task_def/${this.task.taskDefId}/task/${this.task.id}/scorm-player/review`, '_blank'); } } From c022c925bc8b569a416c24696b54580248413001 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 1 Jun 2024 20:14:54 +1000 Subject: [PATCH 49/84] refactor: change url params for test attempts --- src/app/api/models/scorm-player-context.ts | 3 +- src/app/api/services/scorm-adapter.service.ts | 39 +++++++++---------- .../scorm-player/scorm-player.component.ts | 5 ++- src/app/doubtfire.states.ts | 8 ++-- .../task-scorm-card.component.ts | 5 ++- .../scorm-comment/scorm-comment.component.ts | 5 ++- 6 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/app/api/models/scorm-player-context.ts b/src/app/api/models/scorm-player-context.ts index ed7df07c0..c065957ac 100644 --- a/src/app/api/models/scorm-player-context.ts +++ b/src/app/api/models/scorm-player-context.ts @@ -48,7 +48,8 @@ export class ScormPlayerContext { return CMIErrorCodes[value]; } - taskId: number; + projectId: number; + taskDefId: number; user: User; attemptNumber: number; diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index cb6a78095..233c7aed6 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -7,7 +7,6 @@ import {ScormDataModel, ScormPlayerContext} from 'src/app/api/models/doubtfire-m providedIn: 'root', }) export class ScormAdapterService { - private readonly apiBaseUrl = `${API_URL}/test_attempts`; private dataModel: ScormDataModel; private context: ScormPlayerContext; private xhr: XMLHttpRequest; @@ -18,8 +17,12 @@ export class ScormAdapterService { this.xhr = new XMLHttpRequest(); } - set taskId(taskId: number) { - this.context.taskId = taskId; + set projectId(projectId: number) { + this.context.projectId = projectId; + } + + set taskDefId(taskDefId: number) { + this.context.taskDefId = taskDefId; } set mode(mode: 'browse' | 'normal' | 'review') { @@ -51,7 +54,11 @@ export class ScormAdapterService { } // TODO: move this part into the player component - this.xhr.open('GET', `${this.apiBaseUrl}/${this.context.taskId}/latest`, false); + this.xhr.open( + 'GET', + `${API_URL}/projects/${this.context.projectId}/task_def_id/${this.context.taskDefId}/test_attempts/latest`, + false, + ); let noTestFound = false; let startNewTest = false; @@ -82,11 +89,7 @@ export class ScormAdapterService { } if (!startNewTest) { - this.xhr.open( - 'PATCH', - `${this.apiBaseUrl}/${this.context.taskId}/session/${this.context.attemptId}`, - false, - ); + this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, false); this.xhr.send(); console.log(this.xhr.responseText); @@ -96,7 +99,11 @@ export class ScormAdapterService { this.dataModel.restore(currentSession.cmi_datamodel); console.log(this.dataModel.dump()); } else { - this.xhr.open('POST', `${this.apiBaseUrl}/${this.context.taskId}/session`, false); + this.xhr.open( + 'POST', + `${API_URL}/projects/${this.context.projectId}/task_def_id/${this.context.taskDefId}/test_attempts`, + false, + ); this.xhr.send(); console.log(this.xhr.responseText); @@ -126,11 +133,7 @@ export class ScormAdapterService { break; } - this.xhr.open( - 'PATCH', - `${this.apiBaseUrl}/${this.context.taskId}/session/${this.context.attemptId}`, - false, - ); + this.xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, false); this.xhr.setRequestHeader('Content-Type', 'application/json'); const requestData = { cmi_datamodel: JSON.stringify(this.dataModel.dump()), @@ -202,11 +205,7 @@ export class ScormAdapterService { } const xhr = new XMLHttpRequest(); - xhr.open( - 'PATCH', - `${this.apiBaseUrl}/${this.context.taskId}/session/${this.context.attemptId}`, - true, - ); + xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, true); xhr.setRequestHeader('Content-Type', 'application/json'); const requestData = { cmi_datamodel: JSON.stringify(this.dataModel.dump()), diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index e1c68dc87..9c2ee9edb 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -21,7 +21,7 @@ export class ScormPlayerComponent implements OnInit { context: ScormPlayerContext; @Input() - taskId: number; + projectId: number; @Input() taskDefId: number; @@ -41,7 +41,8 @@ export class ScormPlayerComponent implements OnInit { this.globalState.setView(ViewType.OTHER); this.globalState.hideHeader(); - this.scormAdapter.taskId = this.taskId; + this.scormAdapter.projectId = this.projectId; + this.scormAdapter.taskDefId = this.taskDefId; this.scormAdapter.mode = this.mode; window.API_1484_11 = { diff --git a/src/app/doubtfire.states.ts b/src/app/doubtfire.states.ts index 23b60886d..8f48cd06f 100644 --- a/src/app/doubtfire.states.ts +++ b/src/app/doubtfire.states.ts @@ -297,13 +297,13 @@ const ViewAllUnits: NgHybridStateDeclaration = { */ const ScormPlayerState: NgHybridStateDeclaration = { name: 'scorm-player', - url: '/task_def/:task_def_id/task/:task_id/scorm-player/:mode', + url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/:mode', resolve: { - taskId: function ($stateParams) { - return $stateParams.task_id; + projectId: function ($stateParams) { + return $stateParams.project_id; }, taskDefId: function ($stateParams) { - return $stateParams.task_def_id; + return $stateParams.task_definition_id; }, mode: function ($stateParams) { return $stateParams.mode; diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts index 7d7dce6e1..8172bcf1f 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -22,7 +22,10 @@ export class TaskScormCardComponent implements OnInit { } launchScormPlayer(): void { - window.open(`#/task_def/${this.task.taskDefId}/task/${this.task.id}/scorm-player/normal`, '_blank'); + window.open( + `#/projects/${this.task.project.id}/task_def_id/${this.task.taskDefId}/scorm-player/normal`, + '_blank', + ); } requestMoreAttempts(): void { diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts index b58462966..b16184a04 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -15,6 +15,9 @@ export class ScormCommentComponent implements OnInit { ngOnInit() {} reviewScormTest() { - window.open(`#/task_def/${this.task.taskDefId}/task/${this.task.id}/scorm-player/review`, '_blank'); + window.open( + `#/projects/${this.task.project.id}/task_def_id/${this.task.taskDefId}/scorm-player/review`, + '_blank', + ); } } From 561b9241c2f44fd69d3f09c656a025f514bbaf3a Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sun, 2 Jun 2024 02:20:03 +1000 Subject: [PATCH 50/84] feat: enable reviewing, passing, deleting test attempts and add test attempt model and service --- src/app/api/models/doubtfire-model.ts | 3 + .../api/models/task-comment/scorm-comment.ts | 9 ++ src/app/api/models/test-attempt.ts | 20 ++++ src/app/api/services/scorm-adapter.service.ts | 29 +++++ src/app/api/services/task-comment.service.ts | 22 +++- src/app/api/services/test-attempt.service.ts | 103 ++++++++++++++++++ .../scorm-player/scorm-player.component.ts | 11 +- src/app/doubtfire-angular.module.ts | 2 + src/app/doubtfire.states.ts | 38 ++++++- .../scorm-comment.component.html | 11 +- .../scorm-comment/scorm-comment.component.ts | 29 ++++- .../task-comments-viewer.component.html | 4 +- 12 files changed, 259 insertions(+), 22 deletions(-) create mode 100644 src/app/api/models/task-comment/scorm-comment.ts create mode 100644 src/app/api/models/test-attempt.ts create mode 100644 src/app/api/services/test-attempt.service.ts diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index baca2f22d..d6e4230f6 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -34,6 +34,8 @@ 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'; // Users -- are students or staff export * from './user/user'; @@ -58,3 +60,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/task-comment/scorm-comment.ts b/src/app/api/models/task-comment/scorm-comment.ts new file mode 100644 index 000000000..3356b62b9 --- /dev/null +++ b/src/app/api/models/task-comment/scorm-comment.ts @@ -0,0 +1,9 @@ +import {Task, TaskComment, TestAttempt} from '../doubtfire-model'; + +export class ScormComment extends TaskComment { + testAttempt: TestAttempt; + + constructor(task: Task) { + super(task); + } +} diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts new file mode 100644 index 000000000..02bb4bb4e --- /dev/null +++ b/src/app/api/models/test-attempt.ts @@ -0,0 +1,20 @@ +import {Entity} from 'ngx-entity-service'; +import {Task} from './doubtfire-model'; + +export class TestAttempt extends Entity { + id: number; + attemptNumber: number; + terminated: boolean; + completionStatus: boolean; + successStatus: boolean; + scoreScaled: number; + cmiDatamodel: string; + attemptedTime: Date; + + task: Task; + + constructor(task: Task) { + super(); + this.task = task; + } +} diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index 233c7aed6..b6014f14b 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -29,6 +29,10 @@ export class ScormAdapterService { this.context.mode = mode; } + set testAttemptId(testAttemptId: number) { + this.context.attemptId = testAttemptId; + } + get state() { return this.context.state; } @@ -53,6 +57,31 @@ export class ScormAdapterService { break; } + if (this.context.mode === 'review') { + this.xhr.open('GET', `${API_URL}/test_attempts/${this.context.attemptId}/review`, false); + + this.xhr.onload = () => { + if (this.xhr.status >= 200 && this.xhr.status < 400) { + console.log('Retrieved the 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); + + 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', diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts index 5f034b870..c97ec4c3e 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'; @@ -32,7 +32,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 +86,20 @@ 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], + ); + return testAttempt; + }, + }, ); this.mapping.addJsonKey( @@ -103,6 +117,8 @@ export class TaskCommentService extends CachedEntityService { return new DiscussionComment(other); case 'extension': return new ExtensionComment(other); + case 'scorm': + return new ScormComment(other); default: return new TaskComment(other); } 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..7a7ac25ca --- /dev/null +++ b/src/app/api/services/test-attempt.service.ts @@ -0,0 +1,103 @@ +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 = 'test_attempts/:id:'; + protected readonly forTaskEndpoint = + '/projects/:project_id:/task_definition_id/:task_def_id:/test_attempts'; + protected readonly latestCompletedEndpoint = + this.forTaskEndpoint + '/latest?completed=:completed:'; + + constructor(httpClient: HttpClient) { + super(httpClient, API_URL); + + this.mapping.addKeys( + 'id', + 'attemptNumber', + 'terminated', + 'completionStatus', + 'successStatus', + 'scoreScaled', + 'cmiDatamodel', + 'attemptedTime', + ); + } + + public override createInstanceFrom(_json: object, constructorParams: Task): TestAttempt { + return new TestAttempt(constructorParams); + } + + public getAttemptsForTask(task: Task): Observable { + return this.query( + { + project_id: task.project.id, + task_def_id: task.taskDefId, + }, + { + endpointFormat: this.forTaskEndpoint, + constructorParams: task, + }, + ); + } + + 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/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index 9c2ee9edb..4a32eb0ac 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -29,6 +29,9 @@ export class ScormPlayerComponent implements OnInit { @Input() mode: 'browse' | 'normal' | 'review'; + @Input() + testAttemptId: number; + iframeSrc: SafeResourceUrl; constructor( @@ -41,9 +44,13 @@ export class ScormPlayerComponent implements OnInit { this.globalState.setView(ViewType.OTHER); this.globalState.hideHeader(); - this.scormAdapter.projectId = this.projectId; - this.scormAdapter.taskDefId = this.taskDefId; this.scormAdapter.mode = this.mode; + if (this.mode === 'normal') { + this.scormAdapter.projectId = this.projectId; + this.scormAdapter.taskDefId = this.taskDefId; + } else if (this.mode === 'review') { + this.scormAdapter.testAttemptId = this.testAttemptId; + } window.API_1484_11 = { Initialize: () => this.scormAdapter.Initialize(), diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index dabcd4914..5f6e91e77 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -229,6 +229,7 @@ import {ScormPlayerComponent} from './common/scorm-player/scorm-player.component import {ScormAdapterService} from './api/services/scorm-adapter.service'; import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; +import {TestAttemptService} from './api/services/test-attempt.service'; @NgModule({ // Components we declare @@ -406,6 +407,7 @@ import {TaskScormCardComponent} from './projects/states/dashboard/directives/tas IsActiveUnitRole, CreateNewUnitModal, ScormAdapterService, + TestAttemptService, provideLottieOptions({ player: () => player, }), diff --git a/src/app/doubtfire.states.ts b/src/app/doubtfire.states.ts index 8f48cd06f..0338a550a 100644 --- a/src/app/doubtfire.states.ts +++ b/src/app/doubtfire.states.ts @@ -295,9 +295,9 @@ const ViewAllUnits: NgHybridStateDeclaration = { /** * Define the SCORM Player state. */ -const ScormPlayerState: NgHybridStateDeclaration = { - name: 'scorm-player', - url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/:mode', +const ScormPlayerNormalState: NgHybridStateDeclaration = { + name: 'scorm-player-normal', + url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/normal', resolve: { projectId: function ($stateParams) { return $stateParams.project_id; @@ -305,8 +305,8 @@ const ScormPlayerState: NgHybridStateDeclaration = { taskDefId: function ($stateParams) { return $stateParams.task_definition_id; }, - mode: function ($stateParams) { - return $stateParams.mode; + mode: function () { + return 'normal'; }, }, views: { @@ -320,6 +320,31 @@ const ScormPlayerState: NgHybridStateDeclaration = { }, }; +const ScormPlayerReviewState: NgHybridStateDeclaration = { + name: 'scorm-player-review', + url: '/task_def_id/:task_definition_id/scorm-player/review/:test_attempt_id', + resolve: { + taskDefId: function ($stateParams) { + return $stateParams.task_definition_id; + }, + testAttemptId: function ($stateParams) { + return $stateParams.test_attempt_id; + }, + mode: function () { + return 'review'; + }, + }, + views: { + main: { + component: ScormPlayerComponent, + }, + }, + data: { + pageTitle: 'Review Knowledge Check', + roleWhitelist: ['Student', 'Tutor', 'Convenor', 'Admin'], + }, +}; + /** * Export the list of states we have created in angular */ @@ -335,5 +360,6 @@ export const doubtfireStates = [ ViewAllProjectsState, ViewAllUnits, AdministerUnits, - ScormPlayerState, + ScormPlayerNormalState, + ScormPlayerReviewState, ]; diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html index ffe375f19..b0536d2ce 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -1,10 +1,13 @@ -
-
+
+

- -
+
+ + + +

diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts index b16184a04..201e35544 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -1,23 +1,42 @@ import {Component, OnInit, Input} from '@angular/core'; -import {Task, TaskComment} from 'src/app/api/models/doubtfire-model'; +import {Task, ScormComment, User, UserService, TestAttemptService} from 'src/app/api/models/doubtfire-model'; @Component({ - selector: 'scorm-comment', + selector: 'f-scorm-comment', templateUrl: './scorm-comment.component.html', styleUrls: ['./scorm-comment.component.scss'], }) export class ScormCommentComponent implements OnInit { @Input() task: Task; - @Input() comment: TaskComment; + @Input() comment: ScormComment; - constructor() {} + user: User; + + constructor( + private userService: UserService, + private testAttemptService: TestAttemptService, + ) { + this.user = this.userService.currentUser; + } ngOnInit() {} + get canOverridePass(): boolean { + return this.user.isStaff && !this.comment.testAttempt.successStatus; + } + reviewScormTest() { window.open( - `#/projects/${this.task.project.id}/task_def_id/${this.task.taskDefId}/scorm-player/review`, + `#/task_def_id/${this.task.taskDefId}/scorm-player/review/${this.comment.testAttempt.id}`, '_blank', ); } + + passScormAttempt() { + this.testAttemptService.overrideSuccessStatus(this.comment.testAttempt.id, true); + } + + deleteScormAttempt() { + this.testAttemptService.deleteAttempt(this.comment.testAttempt.id); + } } diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index aa20275ff..f6228c576 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -73,11 +73,11 @@
- + >
From e605a3cfb56f8979b6e8d3ddf16dab7a8d253c03 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sun, 2 Jun 2024 21:00:31 +1000 Subject: [PATCH 51/84] refactor: use new alert service for scorm editor --- .../task-definition-scorm.component.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts index 8f2b72fcb..3e41aa39d 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts @@ -1,10 +1,10 @@ -import { Component, Inject, Input } from '@angular/core'; -import { FormControl, Validators } from '@angular/forms'; -import { alertService } from 'src/app/ajs-upgraded-providers'; -import { TaskDefinition } from 'src/app/api/models/task-definition'; -import { Unit } from 'src/app/api/models/unit'; -import { TaskDefinitionService } from 'src/app/api/services/task-definition.service'; -import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; +import {Component, Input} from '@angular/core'; +import {FormControl, Validators} from '@angular/forms'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {Unit} from 'src/app/api/models/unit'; +import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; @Component({ selector: 'f-task-definition-scorm', @@ -16,8 +16,8 @@ export class TaskDefinitionScormComponent { constructor( private fileDownloaderService: FileDownloaderService, - @Inject(alertService) private alerts: any, - private taskDefinitionService: TaskDefinitionService + private alerts: AlertService, + private taskDefinitionService: TaskDefinitionService, ) {} public attemptLimitControl = new FormControl('', [Validators.max(100), Validators.min(0)]); @@ -41,9 +41,11 @@ export class TaskDefinitionScormComponent { } public uploadScormData(files: FileList) { - console.log(Array.from(files).map(f => f.type)); + console.log(Array.from(files).map((f) => f.type)); const validMimeTypes = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip']; - const validFiles = Array.from(files as ArrayLike).filter(f => validMimeTypes.includes(f.type)); + const validFiles = Array.from(files as ArrayLike).filter((f) => + validMimeTypes.includes(f.type), + ); if (validFiles.length > 0) { const file = validFiles[0]; this.taskDefinitionService.uploadScormData(this.taskDefinition, file).subscribe({ From 58c24c3a6760af168a918bc099e755f475eac5f0 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Mon, 3 Jun 2024 00:24:20 +1000 Subject: [PATCH 52/84] fix: show correct attempts left and allow tutor to review attempt always --- src/app/api/models/task.ts | 20 +++++++++++++ src/app/api/services/test-attempt.service.ts | 20 ++----------- .../task-scorm-card.component.html | 18 ++++++----- .../task-scorm-card.component.ts | 30 +++++++++++-------- .../scorm-comment.component.html | 18 ++++++++--- 5 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 556d0a7dd..86ac445fc 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -15,6 +15,8 @@ import { TaskCommentService, TaskSimilarity, TaskSimilarityService, + TestAttempt, + TestAttemptService, } from './doubtfire-model'; import {Grade} from './grade'; import {LOCALE_ID} from '@angular/core'; @@ -53,6 +55,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; @@ -770,4 +773,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/services/test-attempt.service.ts b/src/app/api/services/test-attempt.service.ts index 7a7ac25ca..6c32ed01a 100644 --- a/src/app/api/services/test-attempt.service.ts +++ b/src/app/api/services/test-attempt.service.ts @@ -10,11 +10,10 @@ import {HttpClient} from '@angular/common/http'; @Injectable() export class TestAttemptService extends CachedEntityService { - protected readonly endpointFormat = 'test_attempts/:id:'; - protected readonly forTaskEndpoint = - '/projects/:project_id:/task_definition_id/:task_def_id:/test_attempts'; + protected readonly endpointFormat = + '/projects/:project_id:/task_def_id/:task_def_id:/test_attempts'; protected readonly latestCompletedEndpoint = - this.forTaskEndpoint + '/latest?completed=:completed:'; + this.endpointFormat + '/latest?completed=:completed:'; constructor(httpClient: HttpClient) { super(httpClient, API_URL); @@ -35,19 +34,6 @@ export class TestAttemptService extends CachedEntityService { return new TestAttempt(constructorParams); } - public getAttemptsForTask(task: Task): Observable { - return this.query( - { - project_id: task.project.id, - task_def_id: task.taskDefId, - }, - { - endpointFormat: this.forTaskEndpoint, - constructorParams: task, - }, - ); - } - public getLatestCompletedAttempt(task: Task): Observable { return this.get( { diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index 67265461c..9d518fb1c 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -3,24 +3,28 @@ Knowledge Check +

You have to successfully pass this knowledge check to complete the task.

- You have to successfully pass this knowledge check to complete the task. -

-

- You have {{ (task.definition.scormAttemptLimit > 0) ? task.definition.scormAttemptLimit : 'unlimited' }} attempts to complete this test. + You have {{ attemptsLeft !== undefined ? attemptsLeft : 'unlimited' }} attempts left to + complete this test.

- There will be an increased time delay between test attempts. First 2 attempts will not have a time delay in between. + There will be an increased time delay between test attempts. First 2 attempts will not have a + time delay in between.

- - diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts index 8172bcf1f..cdeb73df0 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -1,23 +1,29 @@ -import {Component, Input, OnInit} from '@angular/core'; -import {Task} from 'src/app/api/models/task'; -import {TaskService} from 'src/app/api/services/task.service'; +import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; +import {Task} from 'src/app/api/models/doubtfire-model'; @Component({ selector: 'f-task-scorm-card', templateUrl: './task-scorm-card.component.html', styleUrls: ['./task-scorm-card.component.scss'], }) -export class TaskScormCardComponent implements OnInit { +export class TaskScormCardComponent implements OnChanges { @Input() task: Task; attemptsLeft: number; - constructor( - private taskService: TaskService, - ) {} - - ngOnInit(): void { - if (this.task) { + ngOnChanges(changes: SimpleChanges) { + if (changes.task && changes.task.currentValue) { + this.attemptsLeft = undefined; + this.getAttemptsLeft(); + } + } + getAttemptsLeft(): void { + if (this.task.definition.scormAttemptLimit != 0) { + this.task.fetchTestAttempts().subscribe((attempts) => { + let count = attempts.length; + if (count > 0 && attempts[0].terminated === false) count--; + this.attemptsLeft = this.task.definition.scormAttemptLimit - count; + }); } } @@ -28,7 +34,5 @@ export class TaskScormCardComponent implements OnInit { ); } - requestMoreAttempts(): void { - - } + requestMoreAttempts(): void {} } diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html index b0536d2ce..c1dacaa78 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -2,11 +2,21 @@

-
+
- - - + + +

From d904ffd6fd674f6e61894d517f5aa147d1db6d29 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Wed, 5 Jun 2024 15:17:47 +1000 Subject: [PATCH 53/84] feat: enable students to request extra scorm attempt --- src/app/api/models/doubtfire-model.ts | 1 + .../task-comment/scorm-extension-comment.ts | 38 +++++++++++ src/app/api/models/task.ts | 1 + src/app/api/services/task-comment.service.ts | 43 ++++++++++++ src/app/api/services/task.service.ts | 1 + .../scorm-extension-modal.component.html | 45 +++++++++++++ .../scorm-extension-modal.component.ts | 67 +++++++++++++++++++ .../scorm-extension-modal.service.ts | 26 +++++++ src/app/doubtfire-angular.module.ts | 4 ++ .../task-scorm-card.component.html | 6 +- .../task-scorm-card.component.ts | 11 ++- .../scorm-extension-comment.component.html | 32 +++++++++ .../scorm-extension-comment.component.scss | 55 +++++++++++++++ .../scorm-extension-comment.component.ts | 63 +++++++++++++++++ .../task-comments-viewer.component.html | 5 ++ .../task-comments-viewer.component.scss | 4 +- .../task-comments-viewer.component.ts | 8 ++- 17 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 src/app/api/models/task-comment/scorm-extension-comment.ts create mode 100644 src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.html create mode 100644 src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts create mode 100644 src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts create mode 100644 src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html create mode 100644 src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss create mode 100644 src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index d6e4230f6..fffc361a4 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -36,6 +36,7 @@ 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'; 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..3b4dfbccb --- /dev/null +++ b/src/app/api/models/task-comment/scorm-extension-comment.ts @@ -0,0 +1,38 @@ +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 deny(): Observable { + this.granted = false; + return this.assessScormExtension(); + } + + public grant(): Observable { + this.granted = true; + return this.assessScormExtension(); + } +} diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 86ac445fc..ba4bce8e0 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -32,6 +32,7 @@ export class Task extends Entity { status: TaskStatusEnum = 'not_started'; dueDate: Date; extensions: number; + scormExtensions: number; submissionDate: Date; completionDate: Date; timesAssessed: number; diff --git a/src/app/api/services/task-comment.service.ts b/src/app/api/services/task-comment.service.ts index c97ec4c3e..e7f76d37b 100644 --- a/src/app/api/services/task-comment.service.ts +++ b/src/app/api/services/task-comment.service.ts @@ -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:"; @@ -100,6 +105,9 @@ export class TaskCommentService extends CachedEntityService { return testAttempt; }, }, + + // Scorm Extension Comments + ['taskScormExtensions', 'scorm_extensions'] ); this.mapping.addJsonKey( @@ -119,6 +127,8 @@ export class TaskCommentService extends CachedEntityService { return new ExtensionComment(other); case 'scorm': return new ScormComment(other); + case 'scorm_extension': + return new ScormExtensionComment(other); default: return new TaskComment(other); } @@ -218,6 +228,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.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/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 + } + +
+ +
+ + +
diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts new file mode 100644 index 000000000..c7f583382 --- /dev/null +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.component.ts @@ -0,0 +1,67 @@ +import {Component, Inject, LOCALE_ID} from '@angular/core'; +import {MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {TaskComment, TaskCommentService, Task} from 'src/app/api/models/doubtfire-model'; +import {AppInjector} from 'src/app/app-injector'; +import {FormControl, Validators, FormGroup, FormGroupDirective, NgForm} from '@angular/forms'; +import {ErrorStateMatcher} from '@angular/material/core'; +import {AlertService} from '../../services/alert.service'; + +/** Error when invalid control is dirty, touched, or submitted. */ +export class ReasonErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)); + } +} + +@Component({ + selector: 'f-scorm-extension-modal', + templateUrl: './scorm-extension-modal.component.html', +}) +export class ScormExtensionModalComponent { + protected reasonMinLength: number = 15; + protected reasonMaxLength: number = 256; + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: {task: Task; afterApplication?: () => void}, + private alerts: AlertService, + ) {} + + matcher = new ReasonErrorStateMatcher(); + currentLocale = AppInjector.get(LOCALE_ID); + extensionData = new FormGroup({ + extensionReason: new FormControl('', [ + Validators.required, + Validators.minLength(this.reasonMinLength), + Validators.maxLength(this.reasonMaxLength), + ]), + }); + + private scrollCommentsDown(): void { + setTimeout(() => { + const objDiv = document.querySelector('div.comments-body'); + // let wrappedResult = angular.element(objDiv); + objDiv.scrollTop = objDiv.scrollHeight; + }, 50); + } + + submitApplication() { + const tcs: TaskCommentService = AppInjector.get(TaskCommentService); + tcs + .requestScormExtension(this.extensionData.controls.extensionReason.value, this.data.task) + .subscribe({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + next: ((tc: TaskComment) => { + this.alerts.success('Extra attempt requested.', 2000); + this.scrollCommentsDown(); + if (typeof this.data.afterApplication === 'function') { + this.data.afterApplication(); + } + }).bind(this), + error: ((response: never) => { + this.alerts.error('Error requesting extra attempt ' + response); + console.log(response); + }).bind(this), + }); + } +} diff --git a/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts new file mode 100644 index 000000000..7e9b46f8e --- /dev/null +++ b/src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service.ts @@ -0,0 +1,26 @@ +import {Injectable} from '@angular/core'; +import {Task} from 'src/app/api/models/task'; +import {MatDialogRef, MatDialog} from '@angular/material/dialog'; +import {ScormExtensionModalComponent} from './scorm-extension-modal.component'; + +@Injectable({ + providedIn: 'root', +}) +export class ScormExtensionModalService { + constructor(public dialog: MatDialog) {} + + public show(task: Task, afterApplication?: any) { + let dialogRef: MatDialogRef; + + dialogRef = this.dialog.open(ScormExtensionModalComponent, { + data: { + task, + afterApplication, + }, + }); + + dialogRef.afterOpened().subscribe((result: any) => {}); + + dialogRef.afterClosed().subscribe((result: any) => {}); + } +} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 5f6e91e77..67e3c3f1d 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -230,6 +230,8 @@ import {ScormAdapterService} from './api/services/scorm-adapter.service'; import {ScormCommentComponent} from './tasks/task-comments-viewer/scorm-comment/scorm-comment.component'; import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; import {TestAttemptService} from './api/services/test-attempt.service'; +import {ScormExtensionCommentComponent} from './tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component'; +import {ScormExtensionModalComponent} from './common/modals/scorm-extension-modal/scorm-extension-modal.component'; @NgModule({ // Components we declare @@ -335,6 +337,8 @@ import {TestAttemptService} from './api/services/test-attempt.service'; ScormPlayerComponent, ScormCommentComponent, TaskScormCardComponent, + ScormExtensionCommentComponent, + ScormExtensionModalComponent, ], // Services we provide providers: [ diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index 9d518fb1c..1929afb24 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -22,10 +22,10 @@ diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts index cdeb73df0..2713c9ee3 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -1,5 +1,6 @@ import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; import {Task} from 'src/app/api/models/doubtfire-model'; +import {ScormExtensionModalService} from 'src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service'; @Component({ selector: 'f-task-scorm-card', @@ -10,6 +11,8 @@ export class TaskScormCardComponent implements OnChanges { @Input() task: Task; attemptsLeft: number; + constructor(private extensions: ScormExtensionModalService) {} + ngOnChanges(changes: SimpleChanges) { if (changes.task && changes.task.currentValue) { this.attemptsLeft = undefined; @@ -22,7 +25,7 @@ export class TaskScormCardComponent implements OnChanges { this.task.fetchTestAttempts().subscribe((attempts) => { let count = attempts.length; if (count > 0 && attempts[0].terminated === false) count--; - this.attemptsLeft = this.task.definition.scormAttemptLimit - count; + this.attemptsLeft = this.task.definition.scormAttemptLimit + this.task.scormExtensions - count; }); } } @@ -34,5 +37,9 @@ export class TaskScormCardComponent implements OnChanges { ); } - requestMoreAttempts(): void {} + requestExtraAttempt(): void { + this.extensions.show(this.task, () => { + this.task.refresh(); + }); + } } diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html new file mode 100644 index 000000000..fa7c4cb39 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.html @@ -0,0 +1,32 @@ +
+ @if (comment.assessed) { +
+
+

reason: {{ comment.text }}

+
+ } + + @if (!comment.assessed) { +
+
+

+ {{ message }}
+ reason: {{ comment.text }} +

+ @if (isNotStudent) { +
+ + +
+ } +
+
+ } +
diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss new file mode 100644 index 000000000..c2917f902 --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.scss @@ -0,0 +1,55 @@ +div { + width: 100%; +} + +p { + color: #2c2c2c; + text-align: center; +} + +hr { + width: 100%; +} + +.hr-fade { + background: linear-gradient(to right, transparent, #9696969d, transparent); + width: 100%; +} + +.fade-text { + color: #9696969d; + opacity: 0.8; +} + +.hr-text { + margin: 0; + line-height: 1em; + position: relative; + outline: 0; + border: 0; + color: black; + text-align: center; + height: 1.5em; + opacity: 0.8; + &:before { + content: ""; + background: linear-gradient(to right, transparent, #9696969d, transparent); + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 1px; + } + &:after { + content: attr(data-content); + position: relative; + display: inline-block; + color: black; + + padding: 0 0.5em; + line-height: 1.5em; + + color: #9696969d; + background-color: #fff; + } +} diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts new file mode 100644 index 000000000..eb1790edd --- /dev/null +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts @@ -0,0 +1,63 @@ +import {Component, OnInit, Input} from '@angular/core'; +import {ScormExtensionComment, TaskComment, Task} from 'src/app/api/models/doubtfire-model'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-scorm-extension-comment', + templateUrl: './scorm-extension-comment.component.html', + styleUrls: ['./scorm-extension-comment.component.scss'], +}) +export class ScormExtensionCommentComponent implements OnInit { + @Input() comment: ScormExtensionComment; + @Input() task: Task; + + constructor(private alerts: AlertService) {} + + private handleError(error: any) { + this.alerts.error('Error: ' + error.data.error, 6000); + } + + ngOnInit() {} + + get message() { + const studentName = this.comment.author.name; + if (this.comment.assessed && this.comment.granted) { + return 'Extra attempt granted.'; + } else if (this.comment.assessed && !this.comment.granted) { + return 'Extra attempt request rejected.'; + } + const subject = this.isStudent ? 'You have ' : studentName + ' has '; + const message = 'requested an extra attempt for the knowledge check.'; + return subject + message; + } + + get isStudent() { + return !this.isNotStudent; + } + + get isNotStudent() { + return this.task.unit.currentUserIsStaff; + } + + denyExtension() { + this.comment.deny().subscribe({ + next: (tc: TaskComment) => { + this.alerts.success('Attempt request denied', 2000); + }, + error: (response) => { + this.handleError(response); + }, + }); + } + + grantExtension() { + this.comment.grant().subscribe({ + next: (tc: TaskComment) => { + this.alerts.success('Attempt request granted', 2000); + }, + error: (response) => { + this.handleError(response); + }, + }); + } +} diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index f6228c576..81b09e601 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -95,6 +95,11 @@
+
+ + +
+
diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss index d82983038..a1b481194 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.scss @@ -140,7 +140,7 @@ $comment-inner-border-radius: 4px; } } - .comment-container .comment-extension { + .comment-container .comment-extension, .comment-container .comment-scorm-extension { width: 100%; } @@ -354,7 +354,7 @@ $comment-inner-border-radius: 4px; } } - .comment .extension-bubble { + .comment .extension-bubble, .comment .scorm_extension-bubble { width: 100%; background-color: transparent; } diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts index 8ad719f33..857f4581f 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts @@ -154,7 +154,13 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit { } shouldShowAuthorIcon(commentType: string) { - return !(commentType === 'extension' || commentType === 'status' || commentType == 'assessment' || commentType == 'scorm'); + return !( + commentType === 'extension' || + commentType === 'status' || + commentType == 'assessment' || + commentType === 'scorm' || + commentType === 'scorm_extension' + ); } commentClasses(comment: TaskComment): object { From 97e1ea187b769a51ebb66b82d76f21193bb29386 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Wed, 5 Jun 2024 23:15:15 +1000 Subject: [PATCH 54/84] fix: add auth headers to scorm adapter xhr requests --- src/app/api/services/scorm-adapter.service.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/api/services/scorm-adapter.service.ts b/src/app/api/services/scorm-adapter.service.ts index b6014f14b..085d41610 100644 --- a/src/app/api/services/scorm-adapter.service.ts +++ b/src/app/api/services/scorm-adapter.service.ts @@ -59,6 +59,8 @@ export class ScormAdapterService { 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.onload = () => { if (this.xhr.status >= 200 && this.xhr.status < 400) { @@ -88,6 +90,8 @@ export class ScormAdapterService { `${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; @@ -119,6 +123,8 @@ export class ScormAdapterService { 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); @@ -133,6 +139,8 @@ export class ScormAdapterService { `${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); @@ -163,6 +171,8 @@ export class ScormAdapterService { } 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()), @@ -233,26 +243,28 @@ export class ScormAdapterService { break; } - const xhr = new XMLHttpRequest(); - xhr.open('PATCH', `${API_URL}/test_attempts/${this.context.attemptId}`, true); - xhr.setRequestHeader('Content-Type', 'application/json'); + 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()), }; - xhr.send(JSON.stringify(requestData)); + - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 400) { + this.xhr.onload = () => { + if (this.xhr.status >= 200 && this.xhr.status < 400) { console.log('DataModel saved successfully.'); } else { - console.error('Error saving DataModel:', xhr.responseText); + console.error('Error saving DataModel:', this.xhr.responseText); } }; - xhr.onerror = () => { + this.xhr.onerror = () => { console.error('Request failed.'); }; + this.xhr.send(JSON.stringify(requestData)); this.context.errorCode = 0; return 'true'; } From f0ff40bfd7e86b904adae94bf8cbe5d56371e011 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Wed, 5 Jun 2024 23:42:14 +1000 Subject: [PATCH 55/84] fix: remove attempt number field --- src/app/api/models/scorm-player-context.ts | 1 - src/app/api/models/test-attempt.ts | 1 - src/app/api/services/test-attempt.service.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/app/api/models/scorm-player-context.ts b/src/app/api/models/scorm-player-context.ts index c065957ac..195bc3cd4 100644 --- a/src/app/api/models/scorm-player-context.ts +++ b/src/app/api/models/scorm-player-context.ts @@ -52,7 +52,6 @@ export class ScormPlayerContext { taskDefId: number; user: User; - attemptNumber: number; attemptId: number; learnerName: string; learnerId: number; diff --git a/src/app/api/models/test-attempt.ts b/src/app/api/models/test-attempt.ts index 02bb4bb4e..646b9ed64 100644 --- a/src/app/api/models/test-attempt.ts +++ b/src/app/api/models/test-attempt.ts @@ -3,7 +3,6 @@ import {Task} from './doubtfire-model'; export class TestAttempt extends Entity { id: number; - attemptNumber: number; terminated: boolean; completionStatus: boolean; successStatus: boolean; diff --git a/src/app/api/services/test-attempt.service.ts b/src/app/api/services/test-attempt.service.ts index 6c32ed01a..df5654855 100644 --- a/src/app/api/services/test-attempt.service.ts +++ b/src/app/api/services/test-attempt.service.ts @@ -20,7 +20,6 @@ export class TestAttemptService extends CachedEntityService { this.mapping.addKeys( 'id', - 'attemptNumber', 'terminated', 'completionStatus', 'successStatus', From 9bc48b03905c0a839d78ea13be9817ca73ba54cf Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Thu, 6 Jun 2024 20:53:45 +1000 Subject: [PATCH 56/84] fix: disable launch scorm test button if user is staff --- .../task-scorm-card/task-scorm-card.component.html | 7 ++++++- .../task-scorm-card/task-scorm-card.component.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index 1929afb24..ec385b476 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -14,7 +14,12 @@

- - -
+ }
diff --git a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts index eb1790edd..7585e8d7c 100644 --- a/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-extension-comment/scorm-extension-comment.component.ts @@ -39,17 +39,6 @@ export class ScormExtensionCommentComponent implements OnInit { return this.task.unit.currentUserIsStaff; } - denyExtension() { - this.comment.deny().subscribe({ - next: (tc: TaskComment) => { - this.alerts.success('Attempt request denied', 2000); - }, - error: (response) => { - this.handleError(response); - }, - }); - } - grantExtension() { this.comment.grant().subscribe({ next: (tc: TaskComment) => { From 703563c86253c60ad30d451ee2d8e0fa7ebbfabb Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 11 Jun 2024 22:20:39 +1000 Subject: [PATCH 60/84] feat: disable attempt button if passed and add button to review latest attempt in card --- .../task-scorm-card.component.html | 9 +++- .../task-scorm-card.component.ts | 49 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index ec385b476..4781d88f2 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -18,13 +18,20 @@ mat-stroked-button (click)="launchScormPlayer()" [hidden]="attemptsLeft === 0" - [disabled]="user.isStaff" + [disabled]="user.isStaff || checkIfPassed()" > launch Attempt test + - - - - +@if (isPassed) { + + @if (latestCompletedAttempt.scoreScaled === 1) { + + check + Knowledge Check Passed + + } + @if (latestCompletedAttempt.scoreScaled !== 1) { + + check + Knowledge Check Passed With Mistakes + + } + +

+ You have successfully completed this knowledge check. You can now proceed to submitting task + files. +

+
+ + + +
+} +@if (!isPassed) { + + @if (isPassed === false) { + + close + Knowledge Check Failed + + } + @if (isPassed === undefined) { + + Knowledge Check + + } + +

You have to successfully pass this knowledge check to complete the task.

+

+ You have {{ attemptsLeft !== undefined ? attemptsLeft : 'unlimited' }} attempts left to + complete this test. +

+

+ There will be an increased time delay between test attempts. First 2 attempts will not have + a time delay in between. +

+
+ + + + + +
+} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts index 80744b104..25fc9e479 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -16,6 +16,7 @@ import {ScormExtensionModalService} from 'src/app/common/modals/scorm-extension- export class TaskScormCardComponent implements OnInit, OnChanges { @Input() task: Task; attemptsLeft: number; + isPassed: boolean; latestCompletedAttempt: TestAttempt; user: User; @@ -39,10 +40,13 @@ export class TaskScormCardComponent implements OnInit, OnChanges { refreshAttemptData(): void { this.attemptsLeft = undefined; - this.getAttemptsLeft(); + this.isPassed = undefined; this.latestCompletedAttempt = undefined; + + this.getAttemptsLeft(); this.testAttemptService.getLatestCompletedAttempt(this.task).subscribe((attempt) => { this.latestCompletedAttempt = attempt; + this.isPassed = attempt.successStatus; }); } @@ -57,13 +61,6 @@ export class TaskScormCardComponent implements OnInit, OnChanges { } } - checkIfPassed(): boolean { - if (this.latestCompletedAttempt) { - return this.latestCompletedAttempt.successStatus; - } - return false; - } - launchScormPlayer(): void { window.open( `#/projects/${this.task.project.id}/task_def_id/${this.task.taskDefId}/scorm-player/normal`, From 110271cd7140a31e8dcfca377092ce46181164fc Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 15 Jun 2024 20:23:14 +1000 Subject: [PATCH 62/84] refactor: fix spacing in task scorm card --- .../task-scorm-card.component.html | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index cec59fc93..81e11cf43 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -17,17 +17,16 @@ } -

+

You have successfully completed this knowledge check. You can now proceed to submitting task files.

- - @@ -54,7 +53,7 @@ You have {{ attemptsLeft !== undefined ? attemptsLeft : 'unlimited' }} attempts left to complete this test.

-

+

There will be an increased time delay between test attempts. First 2 attempts will not have a time delay in between.

From 3b7576570394dc0122db73c7052dd5ed97bf0152 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 15 Jun 2024 20:25:41 +1000 Subject: [PATCH 63/84] refactor: add description for scorm time delay --- .../task-definition-scorm.component.html | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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 index 5b3b54c11..5321600cf 100644 --- 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 @@ -24,10 +24,7 @@
-
- - Enable incremental time delays between test attempts - +
Allow students to review completed test attempt @@ -44,5 +41,21 @@ />
+ +
+ + 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. +
}
From ec86e4eca8e9c50ebdaf54c34c302da88dd44b31 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Sat, 15 Jun 2024 21:21:17 +1000 Subject: [PATCH 64/84] feat: prevent uploading files until scorm passed --- src/app/api/models/task-definition.ts | 1 + src/app/api/models/task.ts | 24 +++++++-- .../api/services/task-definition.service.ts | 1 + src/app/api/services/test-attempt.service.ts | 2 +- .../task-scorm-card.component.html | 9 ++-- .../task-scorm-card.component.ts | 54 +++++++------------ .../task-definition-scorm.component.html | 3 ++ 7 files changed, 50 insertions(+), 44 deletions(-) diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index ca2d188eb..050466d74 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -34,6 +34,7 @@ export class TaskDefinition extends Entity { scormEnabled: boolean; hasScormData: boolean; scormAllowReview: boolean; + scormBypassTest: boolean; scormTimeDelayEnabled: boolean; scormAttemptLimit: number = 0; hasTaskAssessmentResources: boolean; diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index ba4bce8e0..2cadc79d7 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -512,9 +512,22 @@ export class Task extends Entity { } public get scormEnabled(): boolean { - return ( - this.definition.scormEnabled && this.definition.hasScormData - ); + return this.definition.scormEnabled && this.definition.hasScormData; + } + + public get scormPassed(): boolean { + if (this.latestCompletedTestAttempt) { + return this.latestCompletedTestAttempt.successStatus; + } + return false; + } + + 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 { @@ -669,12 +682,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); } diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index 3776432fd..9a4a1eeb5 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -96,6 +96,7 @@ export class TaskDefinitionService extends CachedEntityService { 'scormEnabled', 'hasScormData', 'scormAllowReview', + 'scormBypassTest', 'scormTimeDelayEnabled', 'scormAttemptLimit', 'isGraded', diff --git a/src/app/api/services/test-attempt.service.ts b/src/app/api/services/test-attempt.service.ts index df5654855..3e2c46161 100644 --- a/src/app/api/services/test-attempt.service.ts +++ b/src/app/api/services/test-attempt.service.ts @@ -11,7 +11,7 @@ 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'; + 'projects/:project_id:/task_def_id/:task_def_id:/test_attempts'; protected readonly latestCompletedEndpoint = this.endpointFormat + '/latest?completed=:completed:'; diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index 81e11cf43..12df6a493 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -1,6 +1,6 @@ @if (isPassed) { - @if (latestCompletedAttempt.scoreScaled === 1) { + @if (this.task.latestCompletedTestAttempt.scoreScaled === 1) { check @@ -8,7 +8,7 @@ > } - @if (latestCompletedAttempt.scoreScaled !== 1) { + @if (this.task.latestCompletedTestAttempt.scoreScaled !== 1) { check @@ -73,7 +73,10 @@ diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts index 25fc9e479..9860d4774 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.ts @@ -1,11 +1,5 @@ -import {Component, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core'; -import { - Task, - TestAttempt, - TestAttemptService, - User, - UserService, -} from 'src/app/api/models/doubtfire-model'; +import {Component, Input, OnChanges, SimpleChanges} from '@angular/core'; +import {Task, User, UserService} from 'src/app/api/models/doubtfire-model'; import {ScormExtensionModalService} from 'src/app/common/modals/scorm-extension-modal/scorm-extension-modal.service'; @Component({ @@ -13,51 +7,39 @@ import {ScormExtensionModalService} from 'src/app/common/modals/scorm-extension- templateUrl: './task-scorm-card.component.html', styleUrls: ['./task-scorm-card.component.scss'], }) -export class TaskScormCardComponent implements OnInit, OnChanges { +export class TaskScormCardComponent implements OnChanges { @Input() task: Task; attemptsLeft: number; isPassed: boolean; - latestCompletedAttempt: TestAttempt; user: User; constructor( private extensions: ScormExtensionModalService, - private testAttemptService: TestAttemptService, private userService: UserService, ) { this.user = this.userService.currentUser; } - ngOnInit() { - this.refreshAttemptData(); - } - ngOnChanges(changes: SimpleChanges) { - if (changes.task && changes.task.currentValue) { - this.refreshAttemptData(); - } - } - - refreshAttemptData(): void { - this.attemptsLeft = undefined; - this.isPassed = undefined; - this.latestCompletedAttempt = undefined; + if (changes.task && changes.task.currentValue && changes.task.currentValue.scormEnabled) { + this.attemptsLeft = undefined; + this.isPassed = undefined; - this.getAttemptsLeft(); - this.testAttemptService.getLatestCompletedAttempt(this.task).subscribe((attempt) => { - this.latestCompletedAttempt = attempt; - this.isPassed = attempt.successStatus; - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + this.task?.fetchTestAttempts().subscribe((_) => { + this.getAttemptsLeft(); + if (this.task.latestCompletedTestAttempt) this.isPassed = this.task.scormPassed; + }); + } } getAttemptsLeft(): void { if (this.task.definition.scormAttemptLimit != 0) { - this.task.fetchTestAttempts().subscribe((attempts) => { - let count = attempts.length; - if (count > 0 && attempts[0].terminated === false) count--; - this.attemptsLeft = - this.task.definition.scormAttemptLimit + this.task.scormExtensions - count; - }); + const attempts = this.task.testAttemptCache.currentValues; + let count = attempts.length; + if (count > 0 && attempts[0].terminated === false) count--; + this.attemptsLeft = + this.task.definition.scormAttemptLimit + this.task.scormExtensions - count; } } @@ -70,7 +52,7 @@ export class TaskScormCardComponent implements OnInit, OnChanges { reviewLatestCompletedAttempt(): void { window.open( - `#/task_def_id/${this.task.taskDefId}/scorm-player/review/${this.latestCompletedAttempt.id}`, + `#/task_def_id/${this.task.taskDefId}/scorm-player/review/${this.task.latestCompletedTestAttempt.id}`, '_blank', ); } 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 index 5321600cf..c2b89f9d7 100644 --- 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 @@ -28,6 +28,9 @@ Allow students to review completed test attempt + + Allow file upload regardless of test pass status +
Attempt limit From 20da0423450fe3b9b8006660bf6f5e06670f520c Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 18 Jun 2024 19:04:03 +1000 Subject: [PATCH 65/84] fix: change success status descriptions --- .../task-scorm-card/task-scorm-card.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html index 12df6a493..ac1221f87 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component.html @@ -4,7 +4,7 @@ check - Knowledge Check PassedKnowledge Check Passed Without Mistakes } @@ -12,7 +12,7 @@ check - Knowledge Check Passed With MistakesKnowledge Check Passed } @@ -38,7 +38,7 @@ close - Knowledge Check FailedKnowledge Check Unsuccessful } From a2e8a9c1bef2f2cbf3a32683ee84302c061b90ef Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 18 Jun 2024 20:24:39 +1000 Subject: [PATCH 66/84] refactor: show consecutive scorm comments together --- .../api/models/task-comment/scorm-comment.ts | 3 ++ src/app/api/models/task.ts | 9 ++++ .../scorm-comment.component.html | 54 +++++++++++-------- .../scorm-comment.component.scss | 6 +-- .../scorm-comment/scorm-comment.component.ts | 4 -- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/app/api/models/task-comment/scorm-comment.ts b/src/app/api/models/task-comment/scorm-comment.ts index 3356b62b9..b15a2c50c 100644 --- a/src/app/api/models/task-comment/scorm-comment.ts +++ b/src/app/api/models/task-comment/scorm-comment.ts @@ -3,6 +3,9 @@ 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.ts b/src/app/api/models/task.ts index 2cadc79d7..0643f9973 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -17,6 +17,7 @@ import { TaskSimilarityService, TestAttempt, TestAttemptService, + ScormComment, } from './doubtfire-model'; import {Grade} from './grade'; import {LOCALE_ID} from '@angular/core'; @@ -385,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; diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html index c1dacaa78..edde1e057 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -1,24 +1,32 @@ -
-
-
-
-
-
- - - -
-
-
-
+
+
+ +
+ + + + +
+
diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss index 31df73023..7bc5f74d9 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.scss @@ -1,7 +1,3 @@ -div { - width: 100%; -} - p { color: #2c2c2c; text-align: center; @@ -14,7 +10,7 @@ hr { .hr-fade { background: linear-gradient(to right, transparent, #9696969d, transparent); width: 100%; - margin-top: 1px; + margin-top: 6px; } .hr-text { diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts index 87daf5b6c..a16e397ad 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -27,10 +27,6 @@ export class ScormCommentComponent { this.user = this.userService.currentUser; } - get canOverridePass(): boolean { - return this.user.isStaff && !this.comment.testAttempt.successStatus; - } - reviewScormTest() { window.open( `#/task_def_id/${this.task.taskDefId}/scorm-player/review/${this.comment.testAttempt.id}`, From b6887e85b7a06b166519924dd682ef57650a4e32 Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Tue, 18 Jun 2024 20:46:43 +1000 Subject: [PATCH 67/84] fix: delete comment as well as test attempt --- .../scorm-comment/scorm-comment.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts index a16e397ad..d26b914f7 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.ts @@ -50,6 +50,7 @@ export class ScormCommentComponent { 'Are you sure you want to delete this test attempt? This action is final and will delete information associated with this test attempt.', () => { this.testAttemptService.deleteAttempt(this.comment.testAttempt.id); + this.comment.delete(); }, ); } From 8fe2f9e9ca60c02e55c24d0f2bb3eb6dbea8217c Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Thu, 27 Jun 2024 02:13:51 +1000 Subject: [PATCH 68/84] feat: get unique token for scorm asset retrieval --- src/app/api/models/user/user.ts | 1 + src/app/api/services/authentication.service.ts | 18 ++++++++++++++++++ .../scorm-player/scorm-player.component.ts | 18 ++++++++++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/app/api/models/user/user.ts b/src/app/api/models/user/user.ts index 10357a189..552066cfd 100644 --- a/src/app/api/models/user/user.ts +++ b/src/app/api/models/user/user.ts @@ -22,6 +22,7 @@ export class User extends Entity { receiveFeedbackNotifications: boolean; hasRunFirstTimeSetup: boolean; authenticationToken: string; + scormAuthenticationToken: string; pronouns: string | null; acceptedTiiEula: boolean; diff --git a/src/app/api/services/authentication.service.ts b/src/app/api/services/authentication.service.ts index b2cb12f40..fe2b8d0b1 100644 --- a/src/app/api/services/authentication.service.ts +++ b/src/app/api/services/authentication.service.ts @@ -191,4 +191,22 @@ export class AuthenticationService { setTimeout(() => this.router.stateService.go('timeout'), 500); } } + + public getScormToken(): Observable { + return this.httpClient.get(this.AUTH_URL + '/scorm').pipe( + map((response) => { + this.userService.currentUser.scormAuthenticationToken = response['scorm_auth_token']; + localStorage.setItem(this.USERNAME_KEY, JSON.stringify(this.userService.currentUser)); + + // Token expires after 2 hours + setTimeout( + () => { + this.userService.currentUser.scormAuthenticationToken = ''; + localStorage.setItem(this.USERNAME_KEY, JSON.stringify(this.userService.currentUser)); + }, + 1000 * 60 * 60 * 2, + ); + }), + ); + } } diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index 4a32eb0ac..b7f9b1b5a 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -1,6 +1,10 @@ import {Component, OnInit, Input, HostListener} from '@angular/core'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; -import {ScormPlayerContext} from 'src/app/api/models/doubtfire-model'; +import { + AuthenticationService, + ScormPlayerContext, + UserService, +} from 'src/app/api/models/doubtfire-model'; import {ScormAdapterService} from 'src/app/api/services/scorm-adapter.service'; import {AppInjector} from 'src/app/app-injector'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; @@ -37,6 +41,8 @@ export class ScormPlayerComponent implements OnInit { constructor( private globalState: GlobalStateService, private scormAdapter: ScormAdapterService, + private userService: UserService, + private authService: AuthenticationService, private sanitizer: DomSanitizer, ) {} @@ -44,6 +50,14 @@ export class ScormPlayerComponent implements OnInit { this.globalState.setView(ViewType.OTHER); this.globalState.hideHeader(); + if (this.userService.currentUser.scormAuthenticationToken) { + this.setupScorm(); + } else { + this.authService.getScormToken().subscribe(() => this.setupScorm()); + } + } + + setupScorm(): void { this.scormAdapter.mode = this.mode; if (this.mode === 'normal') { this.scormAdapter.projectId = this.projectId; @@ -64,7 +78,7 @@ export class ScormPlayerComponent implements OnInit { }; this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl( - `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.taskDefId}/index.html`, + `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.taskDefId}/${this.userService.currentUser.username}/${this.userService.currentUser.scormAuthenticationToken}/index.html`, ); } From 1b1710f5015456cefad1d74617305981a22962ad Mon Sep 17 00:00:00 2001 From: satikaj <117552851+satikaj@users.noreply.github.com> Date: Wed, 3 Jul 2024 12:47:16 +1000 Subject: [PATCH 69/84] refactor: center scorm comments if no review button --- .../scorm-comment/scorm-comment.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html index edde1e057..0c44be728 100644 --- a/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html +++ b/src/app/tasks/task-comments-viewer/scorm-comment/scorm-comment.component.html @@ -8,7 +8,11 @@ > Review -
+ @if (!user.isStaff && !task.definition.scormAllowReview) { +
+ } @else { +
+ } - -
- } -
+ + @if (taskDefinition.hasScormData) { +
+ + + +
+ }
@@ -33,14 +34,13 @@
- Attempt limit + Limit to this number of attempts - 0 is unlimited
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts index 6a8708d25..8dee18a29 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-scorm/task-definition-scorm.component.ts @@ -20,12 +20,17 @@ export class TaskDefinitionScormComponent { private taskDefinitionService: TaskDefinitionService, ) {} - public attemptLimitControl = new FormControl('', [Validators.max(100), Validators.min(0)]); - public get unit(): Unit { return this.taskDefinition?.unit; } + /** + * Open the SCORM test in a new tab - using preview mode. + */ + public previewScormTest() { + this.taskDefinition.previewScormTest(); + } + public downloadScormData() { this.fileDownloaderService.downloadFile( this.taskDefinition.getScormDataUrl(true), From 1e056037562753dd8628b1df83f913ca06af2dff Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 9 Aug 2024 13:04:07 +1000 Subject: [PATCH 75/84] chore(release): 8.0.24 --- CHANGELOG.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1839482f3..830c69392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [8.0.24](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.23...v8.0.24) (2024-08-09) + + +### Features + +* add ability to preview scorm test ([7ffbea9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7ffbea9f56c393d44571c18f30735e090701b554)) +* add new Numbas Feature ([6f1ac4e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6f1ac4e4c1a78115c426838bce350bce286e44f0)) +* add new Numbas Feature ([8eba913](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8eba913ec4462e1c252a8a698b4b6de67c4ec25f)) +* add numbas component ([7c1734b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7c1734b137ac369b3b605b38749c845df38b9a78)) +* add Numbas config options to task def service keys ([ff28e48](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff28e4802b86dc22be339cc196ef82ade67934f7)) +* add Numbas test attempt model ([097df79](https://github.com/doubtfire-lms/doubtfire-deploy/commit/097df7960d34a8c905ee495b8c6c8b8843e43b36)) +* add Numbas test section on ready for feedback ([e61295c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e61295c50006dbc89cfc0639bb3c68a09a3cda9d)) +* add Numbas test upload section and reorder editor sections ([edbd536](https://github.com/doubtfire-lms/doubtfire-deploy/commit/edbd536e73ace8b6b4cced1481c809fd36524dce)) +* add Numbas upload component and related functions to task-definition model ([4ecaee8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4ecaee8ad1c0caed9a5d848066a173000989f79b)) +* add test attempt service ([a576f48](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a576f484bc434728eab9632c022bccf1ed26cb01)) +* add test attempt service and minor numbas related changes ([0652b56](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0652b56f69eb850c2dfb43be54d07beb4c4eb469)) +* added numbas-lms service code ([471d344](https://github.com/doubtfire-lms/doubtfire-deploy/commit/471d34486f2582e95aac84469187f087277cc079)) +* allow changing scorm review config and add minor UI changes ([fc023af](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fc023af462656e3557e2970f211fa4d59ee1e3d5)) +* change Numbas time delay config to enable incremental delays ([0afa719](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0afa7197293c90a154c1716db91ecadae3677d53)) +* disable attempt button if passed and add button to review latest attempt in card ([703563c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/703563c86253c60ad30d451ee2d8e0fa7ebbfabb)) +* display numbas task comments ([48a31da](https://github.com/doubtfire-lms/doubtfire-deploy/commit/48a31da1442f1e14f497c614053d9002c9f2631b)) +* enable reviewing, passing, deleting test attempts and add test attempt model and service ([561b924](https://github.com/doubtfire-lms/doubtfire-deploy/commit/561b9241c2f44fd69d3f09c656a025f514bbaf3a)) +* enable students to request extra scorm attempt ([d904ffd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d904ffd6fd674f6e61894d517f5aa147d1db6d29)) +* get unique token for scorm asset retrieval ([8fe2f9e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8fe2f9e9ca60c02e55c24d0f2bb3eb6dbea8217c)) +* implement numbas test data upload in task definition service ([2c7dab5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2c7dab555fc9a226b980a8dae96f5738bf517b1b)) +* insert Numbas test rules options in the task editor ([7e52ad5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7e52ad5e5759291d7d61805070ec482eb49c90be)) +* numbas-test-numbas-service ([cee13b7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cee13b727b35f804fc16070885f0e57c422c9982)) +* prevent uploading files until scorm passed ([ec86e4e](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ec86e4eca8e9c50ebdaf54c34c302da88dd44b31)) +* show banner based on scorm success status ([db16172](https://github.com/doubtfire-lms/doubtfire-deploy/commit/db161721fab1359694c44d8df7237cd01892938b)) +* show launch button on ready for feedback if Numbas test is enabled for the task ([17af5b7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/17af5b7fe9e1fbc8e5f18e2fece6e029b017408c)) +* use confirmation modal when passing or deleting test attempts ([3fb25bb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3fb25bb373acab837a3ef787620dd654bfd6803f)) + + +### Bug Fixes + +* add accepted Numbas file types ([bcaa8af](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bcaa8af150aaf68b5cf5fb07ad9cb72037212d5d)) +* add auth headers to scorm adapter xhr requests ([97e1ea1](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97e1ea187b769a51ebb66b82d76f21193bb29386)) +* adjusted edit profile accidental change ([316abc7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/316abc775eeba6ca846c7c742bc88bec61d05a5e)) +* change success status descriptions ([20da042](https://github.com/doubtfire-lms/doubtfire-deploy/commit/20da0423450fe3b9b8006660bf6f5e06670f520c)) +* delete comment as well as test attempt ([b6887e8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b6887e85b7a06b166519924dd682ef57650a4e32)) +* disable launch scorm test button if user is staff ([9bc48b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9bc48b03905c0a839d78ea13be9817ca73ba54cf)) +* ensure counters are incremented after object creation ([2b1dcfc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2b1dcfc717eb770dd623c62ac99d8d961d1c3124)) +* ensure datamodel is updated on termination ([2ac487f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2ac487f13c3743f2b5b805521964bdcbca7cdc15)) +* ensure scorm frame loads when src is available ([6ae4295](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6ae42954162996acf145cef211981153381ff49d)) +* hide config options if numbas test is disabled ([0b15b1c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0b15b1c38b8f09a30f53bb42dccd1971109bce01)) +* indicate task def has scorm data when zip is uploaded ([4b983ba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/4b983bae1f522013f750b8c36d5e1a52624abbbc)) +* initialise SCORM API wrapper before iframe loads ([5d0606c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5d0606c56eaeb929d5873cae7038ce729581512c)) +* integrate Numbas services well with the existing system ([d47b8ed](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d47b8edc745660801984346c4fa67d6bb371f4cb)) +* remove attempt number field ([f0ff40b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f0ff40bfd7e86b904adae94bf8cbe5d56371e011)) +* remove saved scorm token and always get ([c8fc702](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c8fc702bde6f1c448fc585042e5265625552bae8)) +* retrieve test attempt data correctly ([2810ce6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2810ce65d423a137600d621a98bf27dbc221921c)) +* send task id with numbas completed attempt data ([e46214d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e46214dd59d86014bb04d1c31f3a3bf21240e32b)) +* show correct attempts left and allow tutor to review attempt always ([58c24c3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58c24c3a6760af168a918bc099e755f475eac5f0)) +* show correct Numbas test from the task def with all assets loaded ([bee0a0b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bee0a0bb1eb905c964caf7888419effe70553520)) +* show delete and download buttons in editor when Numbas test exists ([821feb9](https://github.com/doubtfire-lms/doubtfire-deploy/commit/821feb93a8a40d095ef4f50adedbfd9216278fa9)) +* show Numbas button component and modify iframe request ([c9c2fbe](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c9c2fbe10db156512946355f3cabfe54461f0eba)) +* show Numbas iframe on top of other elements ([f53befd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/f53befd82b52bba24bc8c593a9266c11f9155d32)) +* show previously configured Numbas attempt limit ([56e1a5d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/56e1a5de44ee275f9544b77909b7472f1020c2c3)) +* update numbas api path ([3df59dc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3df59dcc58f021ef16d81938d1d05473818bc75e)) +* use modal for Numbas and enable authentication ([64b1bfb](https://github.com/doubtfire-lms/doubtfire-deploy/commit/64b1bfb2918993e58a6659948d9621fc7d0b8ba4)) +* use nullish coalescing when retrieving data from the datamodel ([226d919](https://github.com/doubtfire-lms/doubtfire-deploy/commit/226d9193251fb675c92bec8265508477857a4ec9)) + ### [8.0.23](https://github.com/macite/doubtfire-deploy/compare/v8.0.22...v8.0.23) (2024-08-02) diff --git a/package-lock.json b/package-lock.json index 391ed15da..89ded2460 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "8.0.23", + "version": "8.0.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "8.0.23", + "version": "8.0.24", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 691c8a62f..ed0887c53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "8.0.23", + "version": "8.0.24", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From a0c1185bf4af3acb8f58c97cc27021c952861603 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 9 Aug 2024 14:15:19 +1000 Subject: [PATCH 76/84] fix: switch to control shift alt shift fails to be registered on macos... --- .../staff-task-list.component.ts | 16 ++-- .../states/tasks/inbox/inbox.component.ts | 74 +++++++++++-------- 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 5e427de56..db6049a02 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -122,27 +122,28 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.refreshData(); } } + ngOnDestroy(): void { - this.hotkeys.removeShortcuts('alt.shift.arrowdown'); - this.hotkeys.removeShortcuts('alt.shift.arrowup'); + this.hotkeys.removeShortcuts('control.shift.arrowdown'); + this.hotkeys.removeShortcuts('control.shift.arrowup'); } ngOnInit(): void { const registeredHotkeys = this.hotkeys.getHotkeys().map((hotkey) => hotkey.keys); - if (!registeredHotkeys.includes('alt.shift..arrowdown')) { + if (!registeredHotkeys.includes('control.shift.arrowdown')) { this.hotkeys .addShortcut({ - keys: 'alt.shift.arrowdown', + keys: 'control.shift.arrowdown', description: 'Select next task', }) .subscribe(() => this.nextTask()); } - if (!registeredHotkeys.includes('alt.shift.arrowup')) { + if (!registeredHotkeys.includes('control.shift.arrowup')) { this.hotkeys .addShortcut({ - keys: 'alt.shift.arrowup', + keys: 'control.shift.arrowup', description: 'Select previous task', }) .subscribe(() => this.previousTask()); @@ -385,6 +386,9 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } nextTask(): void { + if (!this.filteredTasks) { + return; + } const currentTaskIndex = this.filteredTasks.findIndex((task) => this.isSelectedTask(task)); if (currentTaskIndex >= this.filteredTasks.length) { return; diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index ce178918d..d09d72517 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -1,6 +1,5 @@ import {CdkDragEnd, CdkDragStart, CdkDragMove} from '@angular/cdk/drag-drop'; import { - AfterViewInit, Component, ElementRef, Input, @@ -26,7 +25,7 @@ import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; templateUrl: './inbox.component.html', styleUrls: ['./inbox.component.scss'], }) -export class InboxComponent implements OnInit { +export class InboxComponent implements OnInit, OnDestroy { @Input() unit: Unit; @Input() unitRole: UnitRole; @Input() taskData: {selectedTask: Task; any}; @@ -78,38 +77,48 @@ export class InboxComponent implements OnInit { const ref = this.dialog.open(HotkeysHelpComponent, { // width: '250px', }); - ref.componentInstance.title = `${this.constants.ExternalName.value} Marking Shortcuts`; + ref.componentInstance.title = `${this.constants.ExternalName.value} Feedback Shortcuts`; ref.componentInstance.dismiss.subscribe(() => ref.close()); }); } - this.hotkeys - .addShortcut({ - keys: 'alt.shift.r', - description: 'Mark selected task as redo', - }) - .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('redo')); - - this.hotkeys - .addShortcut({ - keys: 'alt.shift.f', - description: 'Mark selected task as fix', - }) - .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); - - this.hotkeys - .addShortcut({ - keys: 'alt.shift.c', - description: 'Mark selected task as complete', - }) - .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('complete')); - - this.hotkeys - .addShortcut({ - keys: 'alt.shift.d', - description: 'Mark selected task as discuss', - }) - .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('discuss')); + if (!registeredHotkeys.includes('control.shift.r')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.r', + description: 'Mark selected task as redo', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('redo')); + } + + if (!registeredHotkeys.includes('control.shift.f')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.f', + description: 'Mark selected task as fix', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); + } + + if (!registeredHotkeys.includes('altleft.shift.c')) { + this.hotkeys + .addShortcut({ + keys: 'control.Shift.c', + description: 'Mark selected task as complete', + }) + .subscribe(() => + this.selectedTask.selectedTask?.updateTaskStatus('complete') + ); + } + + if (!registeredHotkeys.includes('control.shift.d')) { + this.hotkeys + .addShortcut({ + keys: 'control.shift.d', + description: 'Mark selected task as discuss', + }) + .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('discuss')); + } this.dragMoveAudited$ = this.dragMove$.pipe( withLatestFrom(this.inboxStartSize$), @@ -142,6 +151,11 @@ export class InboxComponent implements OnInit { window.dispatchEvent(new Event('resize')); } + ngOnDestroy(): void { + this.hotkeys.removeShortcuts('control.shift.arrowdown'); + this.hotkeys.removeShortcuts('control.shift.arrowup'); + } + startedDragging(event: CdkDragStart, div: HTMLDivElement) { event.source.element.nativeElement.classList.add('hovering'); const w = div.getBoundingClientRect().width; From 8a2ce3bfe32d99be1bc0667ca1aba55d36f987d2 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 9 Aug 2024 16:13:53 +1000 Subject: [PATCH 77/84] fix: adjust shortcuts and unregister on inbox destroy --- .../units/states/tasks/inbox/inbox.component.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index d09d72517..7e73fcccb 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -82,15 +82,6 @@ export class InboxComponent implements OnInit, OnDestroy { }); } - if (!registeredHotkeys.includes('control.shift.r')) { - this.hotkeys - .addShortcut({ - keys: 'control.shift.r', - description: 'Mark selected task as redo', - }) - .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('redo')); - } - if (!registeredHotkeys.includes('control.shift.f')) { this.hotkeys .addShortcut({ @@ -100,7 +91,7 @@ export class InboxComponent implements OnInit, OnDestroy { .subscribe(() => this.selectedTask.selectedTask?.updateTaskStatus('fix_and_resubmit')); } - if (!registeredHotkeys.includes('altleft.shift.c')) { + if (!registeredHotkeys.includes('control.shift.c')) { this.hotkeys .addShortcut({ keys: 'control.Shift.c', @@ -152,8 +143,10 @@ export class InboxComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.hotkeys.removeShortcuts('control.shift.arrowdown'); - this.hotkeys.removeShortcuts('control.shift.arrowup'); + this.hotkeys.removeShortcuts('control.shift.d'); + this.hotkeys.removeShortcuts('control.shift.f'); + this.hotkeys.removeShortcuts('control.shift.c'); + this.hotkeys.removeShortcuts('shift.?'); } startedDragging(event: CdkDragStart, div: HTMLDivElement) { From fe94f0440a2c1ce457413536481cd2ffc1717247 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 9 Aug 2024 16:22:46 +1000 Subject: [PATCH 78/84] chore(release): 8.0.25 --- CHANGELOG.md | 9 +++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 830c69392..4f765d1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [8.0.25](https://github.com/macite/doubtfire-deploy/compare/v8.0.24...v8.0.25) (2024-08-09) + + +### Bug Fixes + +* adjust shortcuts and unregister on inbox destroy ([8a2ce3b](https://github.com/macite/doubtfire-deploy/commit/8a2ce3bfe32d99be1bc0667ca1aba55d36f987d2)) +* marking shortcuts no longer conflict with common browser shortcuts ([ec7524a](https://github.com/macite/doubtfire-deploy/commit/ec7524aaae040794de76521363c2d885a0425db0)) +* switch to control shift ([a0c1185](https://github.com/macite/doubtfire-deploy/commit/a0c1185bf4af3acb8f58c97cc27021c952861603)) + ### [8.0.24](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.23...v8.0.24) (2024-08-09) diff --git a/package-lock.json b/package-lock.json index 89ded2460..a7032b148 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "8.0.24", + "version": "8.0.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "8.0.24", + "version": "8.0.25", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index ed0887c53..031c7b1a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "8.0.24", + "version": "8.0.25", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 64e41f4aedcf519a2786d7fe4929900f86731896 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 9 Aug 2024 23:06:22 +1000 Subject: [PATCH 79/84] fix: register service names in state resolve functions --- src/app/doubtfire.states.ts | 55 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/app/doubtfire.states.ts b/src/app/doubtfire.states.ts index 7f95af1f0..86b9f2b4d 100644 --- a/src/app/doubtfire.states.ts +++ b/src/app/doubtfire.states.ts @@ -270,7 +270,6 @@ const AdministerUnits: NgHybridStateDeclaration = { }, }; - const ViewAllUnits: NgHybridStateDeclaration = { name: 'view-all-units', url: '/view-all-units', @@ -299,12 +298,18 @@ const ScormPlayerNormalState: NgHybridStateDeclaration = { name: 'scorm-player-normal', url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/normal', resolve: { - projectId: function ($stateParams: {project_id: number}) { - return $stateParams.project_id; - }, - taskDefId: function ($stateParams: {task_definition_id: number}) { - return $stateParams.task_definition_id; - }, + projectId: [ + '$stateParams', + function ($stateParams: {project_id: number}) { + return $stateParams.project_id; + } + ], + taskDefId: [ + '$stateParams', + function ($stateParams: {task_definition_id: number}) { + return $stateParams.task_definition_id; + } + ], mode: function () { return 'normal'; }, @@ -327,15 +332,24 @@ const ScormPlayerStudentReviewState: NgHybridStateDeclaration = { name: 'scorm-player-student-review', url: '/projects/:project_id/task_def_id/:task_definition_id/scorm-player/review/:test_attempt_id', resolve: { - projectId: function ($stateParams) { - return $stateParams.project_id; - }, - taskDefId: function ($stateParams) { - return $stateParams.task_definition_id; - }, - testAttemptId: function ($stateParams) { - return $stateParams.test_attempt_id; - }, + projectId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.project_id; + } + ], + taskDefId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.task_definition_id; + } + ], + testAttemptId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.test_attempt_id; + } + ], mode: function () { return 'review'; }, @@ -355,9 +369,12 @@ const ScormPlayerReviewState: NgHybridStateDeclaration = { name: 'scorm-preview', url: '/task_def_id/:task_definition_id/preview-scorm', resolve: { - taskDefId: function ($stateParams) { - return $stateParams.task_definition_id; - }, + taskDefId: [ + '$stateParams', + function ($stateParams) { + return $stateParams.task_definition_id; + } + ], mode: function () { return 'preview'; }, From 349730ea9d4998ed2d87f7ec6c88daae6b336a2e Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 9 Aug 2024 23:06:41 +1000 Subject: [PATCH 80/84] chore(release): 8.0.26 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f765d1e4..c0f61e572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [8.0.26](https://github.com/macite/doubtfire-deploy/compare/v8.0.25...v8.0.26) (2024-08-09) + + +### Bug Fixes + +* register service names in state resolve functions ([64e41f4](https://github.com/macite/doubtfire-deploy/commit/64e41f4aedcf519a2786d7fe4929900f86731896)) + ### [8.0.25](https://github.com/macite/doubtfire-deploy/compare/v8.0.24...v8.0.25) (2024-08-09) diff --git a/package-lock.json b/package-lock.json index a7032b148..895dc9d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "8.0.25", + "version": "8.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "8.0.25", + "version": "8.0.26", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index 031c7b1a2..bcfbae280 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "8.0.25", + "version": "8.0.26", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 1ec175419ab3ef80f22ac8a6384f6ed40a17c217 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Sat, 10 Aug 2024 00:27:07 +1000 Subject: [PATCH 81/84] fix: encode . in the username in scorm player --- src/app/common/scorm-player/scorm-player.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/common/scorm-player/scorm-player.component.ts b/src/app/common/scorm-player/scorm-player.component.ts index d20b76f00..6c2bdadd2 100644 --- a/src/app/common/scorm-player/scorm-player.component.ts +++ b/src/app/common/scorm-player/scorm-player.component.ts @@ -85,8 +85,11 @@ export class ScormPlayerComponent implements OnInit { window.API_1484_11 = undefined; } + // Encode . as %2e to avoid issue with grape treating the username as a file extension + const username = this.userService.currentUser.username.replaceAll('.', '%2e'); + this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl( - `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.taskDefId}/${this.userService.currentUser.username}/${token}/index.html`, + `${AppInjector.get(DoubtfireConstants).API_URL}/scorm/${this.taskDefId}/${username}/${token}/index.html`, ); } From 57359b34686de49bc36b4f97ae35cea085b6e9cc Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Sat, 10 Aug 2024 00:27:25 +1000 Subject: [PATCH 82/84] chore(release): 8.0.27 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f61e572..4dc8e1bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [8.0.27](https://github.com/macite/doubtfire-deploy/compare/v8.0.26...v8.0.27) (2024-08-09) + + +### Bug Fixes + +* encode . in the username in scorm player ([1ec1754](https://github.com/macite/doubtfire-deploy/commit/1ec175419ab3ef80f22ac8a6384f6ed40a17c217)) + ### [8.0.26](https://github.com/macite/doubtfire-deploy/compare/v8.0.25...v8.0.26) (2024-08-09) diff --git a/package-lock.json b/package-lock.json index 895dc9d7c..55ece4bb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "doubtfire", - "version": "8.0.26", + "version": "8.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "doubtfire", - "version": "8.0.26", + "version": "8.0.27", "license": "AGPL-3.0", "dependencies": { "@angular/animations": "^17.3.6", diff --git a/package.json b/package.json index bcfbae280..d39e2bd7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "doubtfire", - "version": "8.0.26", + "version": "8.0.27", "homepage": "http://github.com/doubtfire-lms/", "description": "Learning and feedback tool.", "license": "AGPL-3.0", From 151ca04ca8da9059890d7846293bf34ae67f05c5 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Wed, 4 Sep 2024 16:20:24 +1000 Subject: [PATCH 83/84] feat: add support for the view language --- src/app/common/file-uploader/file-uploader.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/common/file-uploader/file-uploader.coffee b/src/app/common/file-uploader/file-uploader.coffee index 9f8dac5ff..c492dac11 100644 --- a/src/app/common/file-uploader/file-uploader.coffee +++ b/src/app/common/file-uploader/file-uploader.coffee @@ -68,7 +68,7 @@ angular.module('doubtfire.common.file-uploader', ["ngFileUpload"]) extensions: ['pas', 'cpp', 'c', 'cs', 'csv', 'h', 'hpp', 'java', 'py', 'js', 'html', 'coffee', 'rb', 'css', 'scss', 'yaml', 'yml', 'xml', 'json', 'ts', 'r', 'rmd', 'rnw', 'rhtml', 'rpres', 'tex', 'vb', 'sql', 'txt', 'md', 'jack', 'hack', 'asm', 'hdl', 'tst', 'out', 'cmp', 'vm', 'sh', 'bat', - 'dat', 'ipynb', 'pml'] + 'dat', 'ipynb', 'pml', 'vue'] icon: 'fa-file-code-o' name: 'code' image: From 7984b393752381e0a219dbbdafc0e7b44c3fcdb3 Mon Sep 17 00:00:00 2001 From: Andrew Cain Date: Fri, 18 Oct 2024 13:59:51 +1100 Subject: [PATCH 84/84] fix: correct task scroll into view --- src/app/api/models/task.ts | 2 +- .../directives/student-task-list/student-task-list.coffee | 2 +- .../directives/staff-task-list/staff-task-list.component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index 2aa167adf..ee5d521d2 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -396,7 +396,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 { diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee index a504f3f1c..b4212fa09 100644 --- a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee +++ b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee @@ -44,7 +44,7 @@ angular.module('doubtfire.projects.states.dashboard.directives.student-task-list $scope.taskData.onSelectedTaskChange?(task) scrollToTaskInList(task) if task? scrollToTaskInList = (task) -> - taskEl = document.querySelector("student-task-list-#{task.taskKeyToIdString()}") + taskEl = document.querySelector("##{task.taskKeyToIdString()}") return unless taskEl? funcName = if taskEl.scrollIntoViewIfNeeded? then 'scrollIntoViewIfNeeded' else if taskEl.scrollIntoView? then 'scrollIntoView' return unless funcName? diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 95b498af2..e40367f62 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -363,7 +363,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { } private scrollToTaskInList(task) { - const taskEl = document.querySelector(`staff-task-list #${task.taskKeyToIdString()}`) as any; + const taskEl = document.querySelector(`#${task.taskKeyToIdString()}`) as any; if (!taskEl) { return; }