Skip to content

Commit b17b89a

Browse files
committed
Offline analysis: ability to review attempts
This adds the ability to review attempts to the offline analysis tool. In the list of attempt data, there's a button for each attempt labelled "Review this attempt". It loads the exam in an iframe and creates a SCORM API to review the attempt data. The downloaded data only includes the most recent version of the suspend data object, so we can't offer a timeline view. fixes #1105
1 parent d70e639 commit b17b89a

File tree

5 files changed

+339
-9
lines changed

5 files changed

+339
-9
lines changed

locales/en-GB.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@
128128
"analysis.view results": "View results",
129129
"analysis.upload files": "Upload files",
130130
"analysis.upload more": "Upload more files",
131+
"analysis.back to results": "Back to results",
132+
"analysis.review": "Review",
133+
"analysis.review this": "Review this attempt",
134+
"analysis.reviewing attempt": "Reviewing attempt by {{student_name}}.",
131135
"analysis.attempt data": "Attempt data",
132136
"analysis.select format": "Select data to show",
133137
"analysis.download this table": "Download this table",

themes/default/files/scripts/analysis-display.js

Lines changed: 267 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,42 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
187187
return;
188188
}
189189
}
190+
191+
/** The SCORM data model for this attempt.
192+
*/
193+
scorm_cmi() {
194+
const suspend_data = this.content();
195+
const cmi = {
196+
'cmi.entry': 'resume',
197+
'cmi.mode': 'review',
198+
'cmi.suspend_data': JSON.stringify(suspend_data),
199+
'cmi.learner_name': this.student_name(),
200+
'cmi.score.raw': this.score(),
201+
}
202+
203+
var partAcc = 0;
204+
function visit_part(p,path) {
205+
const prepath = `cmi.interactions.${partAcc}.`;
206+
cmi[prepath+'id'] = path;
207+
cmi[prepath+'learner_response'] = p.student_answer;
208+
partAcc += 1;
209+
if(p.gaps) {
210+
p.gaps.forEach((g,i) => visit_part(g,`${path}g${i}`));
211+
}
212+
if(p.steps) {
213+
p.steps.forEach((s,i) => visit_part(s,`${path}s${i}`));
214+
}
215+
}
216+
suspend_data.questions.forEach((q,i) => {
217+
const qid = `q${i}`;
218+
cmi[`cmi.objectives.${i}.id`] = qid;
219+
cmi[`cmi.objectives.${i}.score.raw`] = q.score;
220+
q.parts.forEach((p,j) => visit_part(p, `${qid}p${j}`));
221+
});
222+
cmi['cmi.objectives._count'] = suspend_data.questions.length;
223+
cmi['cmi.interactions._count'] = partAcc;
224+
return cmi;
225+
}
190226
}
191227

192228
class ViewModel {
@@ -217,6 +253,10 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
217253
/** The current tab the user is on. Toggles between 'upload', 'list_files', and 'table'.*/
218254
this.current_tab = ko.observable('list_files');
219255

256+
this.current_tab.subscribe((v) => {
257+
document.body.dataset.currentTab = v;
258+
});
259+
220260
/** The potential options for table display, along with description*/
221261
/** Descriptive names for each column in the full table.
222262
* This is an array of four rows, containing cells that span several rows or columns.
@@ -236,6 +276,26 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
236276
/** The currently selected table format, from the above labels.
237277
*/
238278
this.table_format = ko.observable(this.table_format_options()[0]);
279+
280+
this.reviewing_file = ko.observable(null);
281+
282+
this.review_file = (file) => {
283+
const changed = file != this.reviewing_file();
284+
this.reviewing_file(file);
285+
this.current_tab('review');
286+
if(changed) {
287+
window.API_1484_11 = new SCORM_API({scorm_cmi: file.scorm_cmi()});
288+
document.getElementById('review-frame').contentWindow.location.reload();
289+
}
290+
};
291+
292+
this.reviewing_attempt_text = ko.computed(function() {
293+
const file = this.reviewing_file();
294+
if(!file) {
295+
return '';
296+
}
297+
return R('analysis.reviewing attempt', {student_name: file.student_name()});
298+
},this);
239299

240300
/** The uploaded files, sorted by status and then by student name.
241301
*/
@@ -425,16 +485,23 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
425485
if(state.files !== undefined) {
426486
this.uploaded_files(state.files.map(fd => {
427487
const af = new AttemptFile(new File([fd.raw_text], fd.filename), this);
428-
af.decrypt();
488+
af.decryptPromise = af.decrypt();
429489
return af;
430490
}));
431491
}
492+
if(state.reviewing_file !== undefined && state.reviewing_file >=0) {
493+
const af = this.uploaded_files()[state.reviewing_file];
494+
af.decryptPromise.then(() => {
495+
this.review_file(af);
496+
});
497+
}
432498

433499
ko.computed(() => {
434500
const state = {
435501
current_tab: this.current_tab(),
436502
table_format: this.table_format().label,
437-
files: this.uploaded_files().map(f => f.as_json())
503+
files: this.uploaded_files().map(f => f.as_json()),
504+
reviewing_file: this.uploaded_files().indexOf(this.reviewing_file())
438505
};
439506

440507
if(window.history.state?.current_tab != state.current_tab) {
@@ -534,7 +601,6 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
534601
/** Handler for the 'change' event on the upload files input.
535602
*/
536603
input_files(vm, evt) {
537-
console.log(evt.target.files);
538604
this.add_files(Array.from(evt.target.files));
539605
}
540606

@@ -564,6 +630,199 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
564630
}
565631
}
566632

633+
634+
635+
636+
/** A SCORM API.
637+
* It provides the `window.API_1484_11` object, which SCORM packages use to interact with the data model.
638+
*/
639+
function SCORM_API(options) {
640+
var data = options.scorm_cmi;
641+
642+
this.callbacks = new CallbackHandler();
643+
644+
this.initialise_data(data);
645+
646+
this.initialise_api();
647+
}
648+
SCORM_API.prototype = {
649+
/** Has the API been initialised?
650+
*/
651+
initialized: false,
652+
653+
/** Has the API been terminated?
654+
*/
655+
terminated: false,
656+
657+
/** The code of the last error that was raised
658+
*/
659+
last_error: 0,
660+
661+
/** Setup the SCORM data model.
662+
* Merge in elements loaded from the page with elements saved to localStorage, taking the most recent value when there's a clash.
663+
*/
664+
initialise_data: function(data) {
665+
// create the data model
666+
this.data = {};
667+
for(var key in data) {
668+
this.data[key] = data[key];
669+
}
670+
671+
/** SCORM display mode - 'normal' or 'review'
672+
*/
673+
this.mode = this.data['cmi.mode'];
674+
675+
/** Is the client allowed to change data model elements?
676+
* Not allowed in review mode.
677+
*/
678+
this.allow_set = this.mode=='normal';
679+
680+
this.callbacks.trigger('initialise_data');
681+
},
682+
683+
/** Initialise the SCORM API and expose it to the SCORM activity
684+
*/
685+
initialise_api: function() {
686+
var sc = this;
687+
688+
/** The API object to expose to the SCORM activity
689+
*/
690+
this.API_1484_11 = {};
691+
['Initialize','Terminate','GetLastError','GetErrorString','GetDiagnostic','GetValue','SetValue','Commit'].forEach(function(fn) {
692+
sc.API_1484_11[fn] = function() {
693+
return sc[fn].apply(sc,arguments);
694+
};
695+
});
696+
697+
/** Counts for the various lists in the data model
698+
*/
699+
this.counts = {
700+
'comments_from_learner': 0,
701+
'comments_from_lms': 0,
702+
'interactions': 0,
703+
'objectives': 0,
704+
}
705+
this.interaction_counts = [];
706+
707+
/** Set the counts based on the existing data model
708+
*/
709+
for(var key in this.data) {
710+
this.check_key_counts_something(key);
711+
}
712+
713+
this.callbacks.trigger('initialise_api');
714+
},
715+
716+
/** For a given data model key, if it belongs to a list, update the counter for that list
717+
*/
718+
check_key_counts_something: function(key) {
719+
var m;
720+
if(m=key.match(/^cmi.(\w+).(\d+)/)) {
721+
var ckey = m[1];
722+
var n = parseInt(m[2]);
723+
this.counts[ckey] = Math.max(n+1, this.counts[ckey]);
724+
this.data['cmi.'+ckey+'._count'] = this.counts[ckey];
725+
if(ckey=='interactions' && this.interaction_counts[n]===undefined) {
726+
this.interaction_counts[n] = {
727+
'objectives': 0,
728+
'correct_responses': 0
729+
}
730+
}
731+
}
732+
if(m=key.match(/^cmi.interactions.(\d+).(objectives|correct_responses).(\d+)/)) {
733+
var n1 = parseInt(m[1]);
734+
var skey = m[2];
735+
var n2 = parseInt(m[3]);
736+
this.interaction_counts[n1][skey] = Math.max(n2+1, this.interaction_counts[n1][skey]);
737+
this.data['cmi.interactions.'+n1+'.'+skey+'._count'] = this.interaction_counts[n1][skey];
738+
}
739+
},
740+
741+
Initialize: function(b) {
742+
this.callbacks.trigger('Initialize',b);
743+
if(b!='' || this.initialized || this.terminated) {
744+
return false;
745+
}
746+
this.initialized = true;
747+
return true;
748+
},
749+
750+
Terminate: function(b) {
751+
this.callbacks.trigger('Terminate',b);
752+
if(b!='' || !this.initialized || this.terminated) {
753+
return false;
754+
}
755+
this.terminated = true;
756+
757+
return true;
758+
},
759+
760+
GetLastError: function() {
761+
return this.last_error;
762+
},
763+
764+
GetErrorString: function(code) {
765+
return "I haven't written any error strings yet.";
766+
},
767+
768+
GetDiagnostic: function(code) {
769+
return "I haven't written any error handling yet.";
770+
},
771+
772+
GetValue: function(key) {
773+
var v = this.data[key];
774+
if(v===undefined) {
775+
return '';
776+
} else {
777+
return v;
778+
}
779+
},
780+
781+
SetValue: function(key,value) {
782+
if(!this.allow_set) {
783+
return;
784+
}
785+
value = (value+'');
786+
var changed = value!=this.data[key];
787+
if(changed) {
788+
this.data[key] = value;
789+
this.check_key_counts_something(key);
790+
}
791+
this.callbacks.trigger('SetValue',key,value,changed);
792+
},
793+
794+
Commit: function(s) {
795+
this.callbacks.trigger('Commit');
796+
return true;
797+
}
798+
}
799+
800+
function CallbackHandler() {
801+
this.callbacks = {};
802+
}
803+
CallbackHandler.prototype = {
804+
on: function(key,fn) {
805+
if(this.callbacks[key] === undefined) {
806+
this.callbacks[key] = [];
807+
}
808+
this.callbacks[key].push(fn);
809+
},
810+
trigger: function(key) {
811+
if(!this.callbacks[key]) {
812+
return;
813+
}
814+
var args = Array.prototype.slice.call(arguments,1);
815+
this.callbacks[key].forEach(function(fn) {
816+
fn.apply(this,args);
817+
});
818+
}
819+
}
820+
821+
822+
823+
824+
825+
567826
Numbas.analysis.init = async function() {
568827

569828
Numbas.display.localisePage();
@@ -584,7 +843,11 @@ Numbas.queueScript('analysis-display', ['base','download','util','csv','display-
584843
document.body.addEventListener('dragover', evt => evt.preventDefault());
585844
document.body.addEventListener('drop', evt => {
586845
evt.preventDefault();
587-
viewModel.add_files(Array.from(evt.dataTransfer.items).map(i => i.getAsFile()));
846+
const files = Array.from(evt.dataTransfer.items)
847+
.filter(f => f.kind == 'file')
848+
.map(i => i.getAsFile())
849+
;
850+
viewModel.add_files(files);
588851
});
589852

590853
ko.applyBindings(viewModel, document.querySelector('body > main#analysis'));

themes/default/files/scripts/exam-display.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,7 @@ Numbas.queueScript('exam-display',['display-util', 'display-base','math','util',
792792
*/
793793
download_attempt_data: async function(){
794794
function sanitise_preamble(s) {
795-
return s.replace(/\n/g,'');
795+
return (s || '').replace(/\n/g,'');
796796
}
797797
const preamble = `Numbas attempt data
798798
Exam: ${sanitise_preamble(this.exam.settings.name)}
@@ -802,7 +802,13 @@ Start time: ${sanitise_preamble(this.exam.start.toISOString())}
802802

803803
let exam_object = Numbas.store.examSuspendData();
804804
let contents = JSON.stringify(exam_object); //this will need to be a json of the exam object, which seems like it should be created somewhere already as we have ways to access it?
805-
let encryptedContents = await Numbas.download.encrypt(contents, this.exam.settings.downloadEncryptionKey);
805+
let encryptedContents;
806+
try {
807+
encryptedContents = await Numbas.download.encrypt(contents, this.exam.settings.downloadEncryptionKey);
808+
} catch(e) {
809+
display.showAlert(R('exam.attempt download security warning'));
810+
return;
811+
}
806812
encryptedContents = util.b64encode(encryptedContents);
807813
const exam_slug = util.slugify(this.exam.settings.name) ;
808814
const student_name_slug = util.slugify(this.exam.student_name);

0 commit comments

Comments
 (0)