/**
* File : report/Reporter.js
* Created : 17/05/2016
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
/* global Promise, window */
import $ from 'jquery';
import SessionReg from './SessionReg.js';
import Encryption from './EncryptMin.js';
import Scorm from './SCORM.js';
import { log, getMsg, getVal } from '../Utils.js';
/**
* This class implements the basic operations related with the processing of times and scores
* done by users playing JClic activities. These operations include: identification of users,
* compilation of data coming from the activities, storage of this data for later use, and
* presentation of summarized results.
*/
export class Reporter {
/**
* Reporter constructor
* @param {module:JClicPlayer.JClicPlayer} ps - The {@link module:JClicPlayer.JClicPlayer JClicPlayer} used to retrieve localized messages
*/
constructor(ps) {
this.ps = ps;
this.sessions = [];
this.started = new Date();
this.initiated = false;
this.info = new ReporterInfo(this);
}
/**
* Registers a new type of reporter
* @param {string} reporterName - The name used to identify this reporter
* @param {function} reporterClass - The reporter class, usually extending Reporter
* @returns {module:report/Reporter.Reporter} - The provided reporter class
*/
static registerClass(reporterName, reporterClass) {
Reporter.CLASSES[reporterName] = reporterClass;
return reporterClass;
}
/**
* Creates a new Reporter of the requested class
* The resulting object must be prepared to operate with a call to its `init` method.
* @param {string} className - Class name of the requested reporter. When `null`, a basic Reporter is created.
* @param {module:JClicPlayer.JClicPlayer} ps - The {@link module:JClicPlayer.JClicPlayer JClicPlayer} used to retrieve localized messages
* @returns {module:report/Reporter.Reporter}
*/
static getReporter(className, ps) {
let result = null;
if (className === null) {
className = 'Reporter';
if (ps.options.hasOwnProperty('reporter'))
className = ps.options.reporter;
}
if (Reporter.CLASSES.hasOwnProperty(className))
result = new Reporter.CLASSES[className](ps);
else
log('error', 'Unknown reporter class: %s', className);
return result;
}
/**
* Returns the `info` element associated to this Reporter.
* @returns {module:report/Reporter.ReporterInfo}
*/
getInfo() {
return this.info.recalc();
}
/**
* Gets a specific property from this reporting system
* @param {string} key - Requested property
* @param {string}+ defaultValue - Default return value when requested property does not exist
* @returns {string}
*/
getProperty(key, defaultValue) {
return defaultValue;
}
/**
* Gets a specific boolean property from this reporting system
* @param {string} key - Requested property
* @param {boolean}+ defaultValue - Default return when requested property does not exist
* @returns {boolean}
*/
getBooleanProperty(key, defaultValue) {
const s = this.getProperty(key, defaultValue === true ? 'true' : 'false');
return key === null ? defaultValue : s === 'true' ? true : false;
}
/**
* Gets the list of groups or organizations currently registered in the system. This
* method should be implemented by classes derived of `Reporter`.
* @returns {external:Promise} - When fulfilled, an array of group data is returned as a result
*/
getGroups() {
return Promise.reject('No groups defined!');
}
/**
* Gets the list of users currently registered in the system, optionally filtered by
* a specific group ID. This method should be implemented by classes derived of `Reporter`.
* @param {string}+ groupId - Optional group ID to be used as a filter criteria
* @returns {external:Promise} - When fulfilled, an object with a collection of user data records
* is returned
*/
getUsers(groupId) {
return Promise.reject('No users defined in ' + groupId);
}
/**
* Gets extended data associated with a specific user. This is a method intended to be
* implemented in subclasses.
* @param {string} _userId - The requested user ID
* @returns {external:Promise} - When fulfilled, an object with user data is returned.
*/
getUserData(_userId) {
return Promise.reject('Unknown user!');
}
/**
* Gets extended data associated with a specific group or organization. This
* is a method intended to be implemented in subclasses.
* @param {string} _groupId - The requested group ID
* @returns {external:Promise} - When fulfilled, an object with group data is returned.
*/
getGroupData(_groupId) {
return Promise.reject('Unknown group!');
}
/**
* Checks if this reporting system manages its own database of users and groups. Defaults to `false`
* @returns {boolean}
*/
userBased() {
if (this.bUserBased === null)
this.bUserBased = this.getBooleanProperty('USER_TABLES', false);
return this.bUserBased;
}
/**
* Allows the current user to create a new group, and asks his name
* @returns {external:Promise} - When fulfilled, the chosen name for the new group is returned.
*/
promptForNewGroup() {
// TODO: Implement promptForNewGroup
return Promise.reject('Remote creation of groups not yet implemented!');
}
/**
* Allows the current user to create a new user ID, and asks his ID and password
* @returns {external:Promise} - When fulfilled, an object with the new user ID and password
* is returned.
*/
promptForNewUser() {
// TODO: Implement promptForNewUser
return Promise.reject('Remote creation of users not yet implemented!');
}
/**
* Allows the current user to select its group or organization from the current groups list
* @returns {external:Promise}
*/
promptGroupId() {
return new Promise((resolve, reject) => {
if (!this.userBased())
reject('This system does not manage users!');
else {
this.getGroups().then((groupList) => {
// Creation of new groups not yet implemented!
if (!groupList || groupList.length < 1)
reject('No groups defined!');
else {
let sel = 0;
const $groupSelect = $('<select/>').attr({ size: Math.max(3, Math.min(15, groupList.length)) });
groupList.forEach(g => $groupSelect.append($('<option/>').attr({ value: g.id }).text(g.name)));
$groupSelect.change(ev => { sel = ev.target.selectedIndex; });
this.ps.skin.showDlg(true, {
main: [
$('<h2/>', { class: 'subtitle' }).html(getMsg('Select group:')),
$groupSelect],
bottom: [
this.ps.skin.$okDlgBtn,
this.ps.skin.$cancelDlgBtn]
}).then(() => {
resolve(groupList[sel].id);
}).catch(reject);
}
}).catch(reject);
}
});
}
/**
* Asks for a valid user ID fulfilling the promise if found, rejecting it otherwise
* @param {boolean}+ forcePrompt - Prompt also if `userId` is already defined (default is `false`)
* @returns {external:Promise}
*/
promptUserId(forcePrompt) {
return new Promise((resolve, reject) => {
if (this.userId !== null && !forcePrompt)
resolve(this.userId);
else if (!this.userBased())
reject('This system does not manage users!');
else {
const $pwdInput = $('<input/>', { type: 'password', size: 8, maxlength: 64 });
if (this.getBooleanProperty('SHOW_USER_LIST', true)) {
this.promptGroupId().then(groupId => {
this.getUsers(groupId).then(userList => {
// Creation of new users not yet implemented
// let userCreationAllowed = this.getBooleanProperty('ALLOW_CREATE_USERS', false)
if (!userList || userList.length < 1)
reject('Group ' + groupId + ' has no users!');
else {
let sel = -1;
const $userSelect = $('<select/>').attr({ size: Math.max(3, Math.min(15, userList.length)) });
userList.forEach(u => $userSelect.append($('<option/>').attr({ value: u.id }).text(u.name)));
$userSelect.change(ev => { sel = ev.target.selectedIndex; });
this.ps.skin.showDlg(true, {
main: [
$('<h2/>', { class: 'subtitle' }).html(getMsg('Select user:')),
$userSelect,
$('<h2/>', { class: 'subtitle' }).html(getMsg('Password:')).append($pwdInput)],
bottom: [
this.ps.skin.$okDlgBtn,
this.ps.skin.$cancelDlgBtn]
}).then(() => {
if (sel >= 0) {
if (userList[sel].pwd && Encryption.Decrypt(userList[sel].pwd) !== $pwdInput.val()) {
window.alert(getMsg('Incorrect password'));
reject('Incorrect password');
} else {
this.userId = userList[sel].id;
resolve(this.userId);
}
} else
reject('No user has been selected');
}).catch(reject);
}
}).catch(reject);
}).catch(reject);
} else {
const $userInput = $('<input/>', { type: 'text', size: 8, maxlength: 64 });
this.ps.skin.showDlg(true, {
main: [
$('<div/>').css({ 'text-align': 'right' })
.append($('<h2/>', { class: 'subtitle' }).html(getMsg('User:'))
.append($userInput))
.append($('<h2/>', { class: 'subtitle' }).html(getMsg('Password:'))
.append($pwdInput))],
bottom: [
this.ps.skin.$okDlgBtn,
this.ps.skin.$cancelDlgBtn]
}).then(() => {
this.getUserData($userInput.val()).then(user => {
if (user.pwd && Encryption.Decrypt(user.pwd) !== $pwdInput.val()) {
window.alert(getMsg('Incorrect password'));
reject('Incorrect password');
} else {
this.userId = user.id;
resolve(this.userId);
}
}).catch(reject);
}).catch(reject);
}
}
});
}
/**
* Builds a complex object containing all the results reported while playing activities
* @returns {object} - The current results
*/
getData() {
// Force the re-calculation of all scores
this.info.recalc();
const result = {
started: this.started.toISOString(),
descriptionKey: this.descriptionKey,
descriptionDetail: this.descriptionDetail,
projects: this.info.numSessions,
sequences: this.info.numSequences,
activitiesDone: this.info.nActivities,
playedOnce: this.info.nActPlayed,
reportable: this.info.reportableActs,
ratioPlayed: Math.round(this.info.ratioPlayed * 100),
activitiesSolved: this.info.nActSolved,
ratioSolved: Math.round(this.info.ratioSolved * 100),
actScore: this.info.nActScore,
partialScore: Math.round(this.info.partialScore * 100),
globalScore: Math.round(this.info.globalScore * 100),
time: Math.round(this.info.tTime / 10) / 100,
actions: this.info.nActions,
sessions: []
};
if (this.userId)
result.userId = this.userId;
else if (this.SCORM)
result.user = this.SCORM.studentName + (this.SCORM.studentId === '' ? '' : ` (${this.SCORM.studentId})`);
this.sessions.forEach(sr => {
if (sr.getInfo().numSequences > 0)
result.sessions.push(sr.getData(false, false));
});
return result;
}
/**
* Initializes this report system with an optional set of parameters.
* Returns a Promise, fulfilled when the reporter is fully initialized.
* @param {object} [options] - Initial settings passed to the reporting system
* @returns {external:Promise}
*/
init(options) {
if (!options)
options = this.ps.options;
this.userId = getVal(options.user);
this.sessionKey = getVal(options.key);
this.sessionContext = getVal(options.context);
this.groupCodeFilter = getVal(options.groupCodeFilter);
this.userCodeFilter = getVal(options.userCodeFilter);
if (options.SCORM !== false) {
this.SCORM = Scorm.getSCORM(this);
if (this.SCORM !== null && this.descriptionKey === Reporter.prototype.descriptionKey)
this.descriptionKey = this.SCORM.getScormType();
}
this.initiated = true;
log('debug', 'Basic Reporter initialized');
return Promise.resolve(true);
}
/**
* Closes this reporting system
* @returns {external:Promise} - A Promise object to be fullfilled when all pending tasks are finished.
*/
end() {
log('debug', 'Basic Reporter ending');
this.endSession();
return Promise.resolve(true);
}
/**
* Finalizes the current sequence
*/
endSequence() {
if (this.currentSession) {
this.currentSession.endSequence();
this.info.valid = false;
}
}
/**
* Finalizes the current session
*/
endSession() {
this.endSequence();
this.currentSession = null;
}
/**
* Creates a new group (method to be implemented in subclasses)
* @param {object} _gd
*/
newGroup(_gd) {
throw "No database!";
}
/**
* Creates a new user (method to be implemented in subclasses)
* @param {object} _ud
*/
newUser(_ud) {
throw "No database!";
}
/**
* This method should be invoked when a new session starts.
* @param {module:project/JClicProject.JClicProject} jcp - The {@link module:project/JClicProject.JClicProject JClicProject} this session refers to.
*/
newSession(jcp) {
this.endSession();
this.currentSession = new SessionReg(jcp);
this.sessions.push(this.currentSession);
this.info.valid = false;
}
/**
* This method should be invoked when a new sequence starts
* @param {module:bags/ActivitySequenceElement.ActivitySequenceElement} ase - The {@link module:bags/ActivitySequenceElement.ActivitySequenceElement ActivitySequenceElement} referenced by this sequence.
*/
newSequence(ase) {
if (this.currentSession) {
this.currentSession.newSequence(ase);
this.info.valid = false;
if (this.SCORM)
this.SCORM.commitInfo();
}
}
/**
* This method should be invoked when the user starts a new activity
* @param {module:Activity.Activity} act - The {@link module:Activity.Activity Activity} reporter has just started
*/
newActivity(act) {
if (this.currentSession) {
this.currentSession.newActivity(act);
this.info.valid = false;
}
}
/**
* This method should be called when the current activity finishes. Data about user's final results
* on the activity will then be saved.
* @param {number} score - The final score, usually in a 0-100 scale.
* @param {number} numActions - The total number of actions done by the user to solve the activity
* @param {boolean} solved - `true` if the activity was finally solved, `false` otherwise.
*/
endActivity(score, numActions, solved) {
if (this.currentSession) {
this.currentSession.endActivity(score, numActions, solved);
this.info.valid = false;
}
}
/**
* Reports a new action done by the user while playing the current activity
* @param {string} type - Type of action (`click`, `write`, `move`, `select`...)
* @param {string}+ source - Description of the object on which the action is done.
* @param {string}+ dest - Description of the object reporter acts as a target of the action (usually in pairings)
* @param {boolean} ok - `true` if the action was OK, `false`, `null` or `undefined` otherwhise
*/
newAction(type, source, dest, ok) {
if (this.currentSession) {
this.currentSession.newAction(type, source, dest, ok);
this.info.valid = false;
}
}
/**
* Gets information about the current sequence
* @returns {module:report/SequenceReg.SequenceRegInfo}
*/
getCurrentSequenceInfo() {
return this.currentSession === null ? null : this.currentSession.getCurrentSequenceInfo();
}
/**
* Gets the name of the current sequence
* @returns {string}
*/
getCurrentSequenceTag() {
return this.currentSession === null ? null : this.currentSession.getCurrentSequenceTag();
}
}
Object.assign(Reporter.prototype, {
/**
* The {@link module:report/Reporter.ReporterInfo ReporterInfo} used to calculate and store global results.
* @name module:report/Reporter.Reporter#info
* @type {module:report/Reporter.ReporterInfo} */
info: null,
/**
* The {@link module:JClicPlayer.JClicPlayer JClicPlayer} used to retrieve messages
* @name module:report/Reporter.Reporter#ps
* @type {module:JClicPlayer.JClicPlayer} */
ps: null,
/**
* A valid SCORM bridge, or `null` if no SCORM API detected.
* @name module:report/Reporter.Reporter#SCORM */
SCORM: null,
/**
* User ID currently associated with this reporting system
* @name module:report/Reporter.Reporter#userId
* @type {string} */
userId: null,
/**
* Optional key to be added as a field in session records
* @name module:report/Reporter.Reporter#sessionKey
* @type {string} */
sessionKey: null,
/**
* A second optional key to be reported as a field in session records
* @name module:report/Reporter.Reporter#sessionContext
* @type {string} */
sessionContext: null,
/**
* Optional filter key to be used in the group selection dialog
* @name module:report/Reporter.Reporter#groupCodeFilter
* @type {string} */
groupCodeFilter: null,
/**
* Another optional filter key to be used in the user selection dialog
* @name module:report/Reporter.Reporter#userCodeFilter
* @type {string} */
userCodeFilter: null,
/**
* Description of this reporting system
* @name module:report/Reporter.Reporter#descriptionKey
* @type {string} */
descriptionKey: 'Results are not currently being saved',
/**
* Additional info to display after the reporter's `description`
* @name module:report/Reporter.Reporter#descriptionDetail
* @type {string} */
descriptionDetail: '',
/**
* Starting date and time of this report
* @name module:report/Reporter.Reporter#started
* @type {external:Date} */
started: null,
/**
* Array of sessions included in this report
* @name module:report/Reporter.Reporter#sessions
* @type {module:report/SessionReg.SessionReg[]} */
sessions: [],
/**
* Currently active session
* @name module:report/Reporter.Reporter#currentSession
* @type {module:report/SessionReg.SessionReg} */
currentSession: null,
/**
* `true` if the system was successfully initiated, `false` otherwise
* @name module:report/Reporter.Reporter#initiated
* @type {boolean} */
initiated: false,
/**
* `true` if the system is connected to a database with user's data.
* When `false`, a generic ID will be used.
* @name module:report/Reporter.Reporter#bUserBased
* @type {boolean} */
bUserBased: null,
/**
* Maximum number of incorrect UserID attempts
* @name module:report/Reporter.Reporter#MAX_USERID_PROMPT_ATTEMPTS
* @type {number} */
MAX_USERID_PROMPT_ATTEMPTS: 3,
});
/**
* This object stores the global results of a {@link module:Reporter.Reporter Reporter}
*/
export class ReporterInfo {
/**
* ReporterInfo constructor
* @param {module:report/Reporter.Reporter} rep - The {@link module:Reporter.Reporter Reporter} associated tho this `Info` object.
*/
constructor(rep) {
this.rep = rep;
}
/**
* Clears all data associated with this ReporterInfo
*/
clear() {
this.numSessions = this.numSequences = this.nActivities = this.reportableActs = this.nActSolved =
this.nActPlayed = this.nActScore = this.nActions = this.ratioSolved = this.ratioPlayed =
this.tScore = this.tTime = this.partialScore = this.globalScore = 0;
this.valid = false;
}
/**
* Computes the value of all global variables based on the data stored in `sessions`
* @returns {module:report/Reporter.ReporterInfo} - This "info" object
*/
recalc() {
if (!this.valid) {
this.clear();
this.rep.sessions.forEach(ses => {
const inf = ses.getInfo();
this.reportableActs += inf.sReg.reportableActs;
if (inf.numSequences > 0) {
this.numSessions++;
this.numSequences += inf.numSequences;
if (inf.nActivities > 0) {
this.nActivities += inf.nActivities;
this.nActPlayed += inf.sReg.actNames.length;
this.nActSolved += inf.nActSolved;
this.nActions += inf.nActions;
if (inf.nActScore > 0) {
this.tScore += inf.tScore * inf.nActScore;
this.nActScore += inf.nActScore;
}
this.tTime += inf.tTime;
}
}
});
if (this.nActivities > 0) {
this.ratioSolved = this.nActSolved / this.nActivities;
if (this.reportableActs > 0)
this.ratioPlayed = this.nActPlayed / this.reportableActs;
this.partialScore = this.tScore / (this.nActScore * 100);
this.globalScore = this.partialScore * this.ratioPlayed;
}
this.valid = true;
}
return this;
}
}
Object.assign(ReporterInfo.prototype, {
/**
* The Reporter linked to this Info object
* @name module:report/Reporter.ReporterInfo#rep
* @type {module:report/Reporter.Reporter}
*/
rep: null,
/**
* When `false`, data must be recalculated
* @name module:report/Reporter.ReporterInfo#valid
* @type {boolean} */
valid: false,
/**
* Number of sessions registered
* @name module:report/Reporter.ReporterInfo#numSessions
* @type {number} */
numSessions: 0,
/**
* Number of sequences played
* @name module:report/Reporter.ReporterInfo#numSequences
* @type {number} */
numSequences: 0,
/**
* Number of activities played
* @name module:report/Reporter.ReporterInfo#nActivities
* @type {number} */
nActivities: 0,
/**
* Number of activities in existing in the played projects suitable to be reported
* @name module:report/Reporter.ReporterInfo#reportableActs
* @type {number} */
reportableActs: 0,
/**
* Number of activities solved
* @name module:report/Reporter.ReporterInfo#nActSolved
* @type {number} */
nActSolved: 0,
/**
* Number of different activities played
* @name module:report/Reporter.ReporterInfo#nActPlayed
* @type {number} */
nActPlayed: 0,
/**
* Global score obtained in all sessions registered by this reporter
* @name module:report/Reporter.ReporterInfo#nActScore
* @type {number} */
nActScore: 0,
/**
* Number of actions done by the user while in this working session
* @name module:report/Reporter.ReporterInfo#nActions
* @type {number} */
nActions: 0,
/**
* Percentage of solved activities
* @name module:report/Reporter.ReporterInfo#ratioSolved
* @type {number} */
ratioSolved: 0,
/**
* Percentage of reportable activities played
* @name module:report/Reporter.ReporterInfo#ratioPlayed
* @type {number} */
ratioPlayed: 0,
/**
* Sum of the scores of all the activities played
* @name module:report/Reporter.ReporterInfo#tScore
* @type {number} */
tScore: 0,
/**
* Global score obtained
* @name module:report/Reporter.ReporterInfo#partialScore
* @type {number} */
partialScore: 0,
/**
* Sum of the playing time reported by each activity (not always equals to the sum of all session's time)
* @name module:report/Reporter.ReporterInfo#tTime
* @type {number} */
tTime: 0,
/**
* Final score based on the percent of reportable activities played. If the user plays all the
* activities, this result equals to `partialScore`.
* @name module:report/Reporter.ReporterInfo#globalScore
* @type {number} */
globalScore: 0,
});
Reporter.Info = ReporterInfo;
/**
* Static list of classes derived from Reporter. It should be filled by Reporter classes at declaration time.
* @type {object}
*/
Reporter.CLASSES = { 'Reporter': Reporter };
export default Reporter;