@universis/evaluations
Version:
Universis evaluations library
709 lines (258 loc) • 26 kB
JavaScript
;var _common = require("@themost/common");
var _data = require("@themost/data");
var _web = require("@themost/web");
var _formInspector = require("@themost/form-inspector");
var _evaluationDocumentModel = _interopRequireDefault(require("./evaluation-document-model"));
var _semver = require("semver");
var _util = _interopRequireDefault(require("util"));
var _fs = _interopRequireDefault(require("fs"));
var _moment = _interopRequireDefault(require("moment"));
var _mailer = require("@themost/mailer");
var _EvaluationService = require("../EvaluationService");var _dec, _dec2, _dec3, _dec4, _dec5, _dec6, _dec7, _dec8, _dec9, _class, _class2;function _interopRequireDefault(obj) {return obj && obj.__esModule ? obj : { default: obj };}function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) {var desc = {};Object.keys(descriptor).forEach(function (key) {desc[key] = descriptor[key];});desc.enumerable = !!desc.enumerable;desc.configurable = !!desc.configurable;if ('value' in desc || desc.initializer) {desc.writable = true;}desc = decorators.slice().reverse().reduce(function (desc, decorator) {return decorator(target, property, desc) || desc;}, desc);if (context && desc.initializer !== void 0) {desc.value = desc.initializer ? desc.initializer.call(context) : void 0;desc.initializer = undefined;}if (desc.initializer === void 0) {Object.defineProperty(target, property, desc);desc = null;}return desc;}const EvaluationAccessToken = require('./evaluation-access-token-model');
const math = require('mathjs');
/**
* @class
*/let
EvaluationEvent = (_dec = _data.EdmMapping.entityType('EvaluationEvent'), _dec2 =
_data.EdmMapping.func('Current', 'EvaluationEvent'), _dec3 =
_data.EdmMapping.func('form', 'Object'), _dec4 =
_data.EdmMapping.func('Results', _data.EdmType.CollectionOf('Object')), _dec5 =
_data.EdmMapping.func('Tokens', _data.EdmType.CollectionOf('EvaluationAccessToken')), _dec6 =
_data.EdmMapping.func('FormAttributes', _data.EdmType.CollectionOf('Object')), _dec7 =
_data.EdmMapping.func('FormAttributesSummary', _data.EdmType.CollectionOf('Object')), _dec8 =
_data.EdmMapping.param('data', 'Object', false, true), _dec9 =
_data.EdmMapping.action('Evaluate', 'Object'), _dec(_class = (_class2 = class EvaluationEvent extends _data.DataObject {/**
* @constructor
*/constructor() {super();} /**
* Get current evaluation based on the evaluation key contained in Evaluation-Key HTTP header
* @param {HttpContext} context
*/static async getCurrent(context) {// get evaluation key
const token = await EvaluationAccessToken.inspect(context, context.user && context.user.authenticationToken);if (token.active === false) {throw new _common.HttpTokenExpiredError(context.__('Evaluation access token has been expired or is invalid.'));} // get evaluation event
// get current date
let evaluationEvent = await context.model('EvaluationEvent').where('id').equal(token.sub).expand('eventStatus').silent().getTypedItem();if (evaluationEvent == null) {throw new _common.HttpNotFoundError('The specified evaluation cannot be found');}if (evaluationEvent.eventStatus) {if (evaluationEvent.eventStatus.alternateName !== 'EventOpened') {throw new _common.DataError('E_CLOSED_EVENT', context.__('The course evaluation has been closed and is no longer available'));}} // check dates
const currentDate = new Date().setHours(0, 0, 0, 0);const startDate = evaluationEvent.startDate.setHours(0, 0, 0, 0);const endDate = evaluationEvent.endDate.setHours(0, 0, 0, 0);if (currentDate < startDate) {throw new _common.DataError('E_NOT_STARTED', context.__('Evaluation period for this course has not been started yet'));}if (currentDate > endDate) {throw new _common.DataError('E_EXPIRED', context.__('Evaluation period for this course has been expired'));}if (evaluationEvent.additionalType !== 'EvaluationEvent') {if (evaluationEvent.additionalType === 'ClassInstructorEvaluationEvent') {const event = await context.model('ClassInstructorEvaluationEvent').where('id').equal(token.sub).expand({ name: 'courseClassInstructor', options: { $expand: 'courseClass($expand=course,year,period,locale),instructor' } }).silent().getTypedItem();const courseClass = event.courseClassInstructor.courseClass;const instructor = event.courseClassInstructor.instructor;const courseClassTitle = courseClass.locale && courseClass.locale.title || courseClass.title;evaluationEvent.courseClass = `${courseClass.course.displayCode}-${courseClassTitle} (${courseClass.year.alternateName}-${courseClass.period.name}) `;evaluationEvent.instructor = `${instructor.familyName} ${instructor.givenName}`;} else {evaluationEvent = await evaluationEvent.silent().getAdditionalObject();}}return evaluationEvent;}async getForm() {const form = await this.property('evaluationDocument').getItem();const service = this.context.getApplication().getService(function PrivateContentService() {}); /**
* @type {string}
*/const formPath = await new Promise((resolve, reject) => {service.resolvePhysicalPath(this.context, form, (err, physicalPath) => {if (err) {return reject(err);}return resolve(physicalPath);});});const readFileAsync = _util.default.promisify(_fs.default.readFile);let content = await readFileAsync(formPath);if (content == null) {throw new _common.HttpNotFoundError('Evaluation form cannot be found');}let buffer = Buffer.from(content, 'base64');let result;result = JSON.parse(buffer.toString('utf8'));try {result.properties = result.properties || {};Object.assign(result.properties, { name: form.resultType, _id: form.id, _event: this.id }); // post-processing
let shouldUpgrade = true;let model = this.context.model(form.resultType);if (model != null) {shouldUpgrade = (0, _semver.lt)(model.version, form.version);}if (shouldUpgrade) {// load for inspector
const inspector = new _formInspector.JsonFormInspector();model = inspector.inspect(result); // post processing steps
Object.assign(model, { source: form.resultType, // add source from evaluation document e.g. Evaluation1
view: form.resultType, // add view from evaluation document e.g. Evaluation1
version: model.version, // set version (follow updates)
hidden: true }); // add evaluation event and evaluation document associations
// for further processing of evaluation results
model.fields.push.apply(model.fields, [{ name: 'evaluationEvent', type: this.getModel().name, editable: false, nullable: false, indexed: true }]); // reset privileges
/**
* @type {DataConfigurationStrategy|*}
*/const dataConfiguration = this.context.getApplication().getConfiguration().getStrategy(_data.DataConfigurationStrategy);model.privileges.splice(0); // get resultPrivileges
const thisModel = dataConfiguration.model(this.getModel().name);model.privileges.push.apply(model.privileges, [{ "mask": 15, "type": "global" }, { "mask": 1, "type": "global", "account": "Administrators" }]); // add extra privileges
if (Array.isArray(thisModel.resultPrivileges)) {model.privileges.push.apply(model.privileges, thisModel.resultPrivileges);}dataConfiguration.setModelDefinition(model); // add entity set
const builder = this.context.getApplication().getService(_data.ODataModelBuilder);builder.addEntitySet(model.name, model.name);if (model.hidden) {builder.removeEntitySet(model.name);} // update version
form.version = model.version;await this.context.model('EvaluationDocument').silent().save(form);}return result;} catch (err) {_common.TraceUtils.error(`Error while decoding evaluation form ${form.id} content.`);_common.TraceUtils.error(err);throw new _common.HttpServerError('An error occurred while decoding evaluation form.');}}async getResults() {const form = await this.getForm();return this.context.model(form.properties.name).where('evaluationEvent').equal(this.id).prepare();}async getAccessTokens() {return this.context.model('EvaluationAccessToken').where('evaluationEvent').equal(this.id).prepare();}async getFormAttributes() {/**
* @type {DataContext|*}
*/const context = this.context;const results = await this.getFormResults(context, this.getId(), null);return results.responses;}async getFormAttributesSummary() {/**
* @type {DataContext|*}
*/const context = this.context;const eventModel = this.getModel().name;const results = await this.getFormResults(context, this.getId(), null);let q = this.context.model(eventModel).where('id').equal(this.getId()).expand({ 'name': 'tokens', 'options': { '$select': 'evaluationEvent,count(evaluationEvent) as total,used', '$groupby': 'evaluationEvent,used' } }, 'organizer');if (eventModel === 'ClassInstructorEvaluationEvent') {q = this.context.model(eventModel).where('id').equal(this.getId()).expand({ 'name': 'tokens', 'options': { '$select': 'evaluationEvent,count(evaluationEvent) as total,used', '$groupby': 'evaluationEvent,used' } }, { 'name': 'courseClassInstructor', 'options': { '$expand': 'courseClass($expand=year,period,course($expand=department)),instructor' } });}results.event = await q.getItem();if (results.event == null) {throw new _common.DataNotFoundError('Evaluation event not found.');} // count used tokens of event
if (results.event.tokens) {const usedTokens = results.event.tokens && results.event.tokens.find((x) => {return x.used === true;});results.event.tokensUsed = usedTokens ? usedTokens.total : 0;} // get avg of questions avg
results.event.responsesAvg = results.responses.length ? math.mean(results.responses.filter((x) => !!x.isNumber).map((x) => {return x.avg;})) : 0;return results;}async getFormResults(context, id, events) {const result = [];const evaluationEvents = [];if (id) {evaluationEvents.push(id);} else {if (Array.isArray(events) && events.length) {evaluationEvents.push.apply(evaluationEvents, events.map((x) => {return x.id || x;}));}}if (evaluationEvents.length === 0) {throw new _common.DataError('Evaluation events not supplied.');}const form = await this.getForm();this.setParentLegend(form); // get model attributes
/**
* @type {DataConfigurationStrategy|*}
*/const dataConfiguration = context.getApplication().getConfiguration().getStrategy(_data.DataConfigurationStrategy);const dataTypes = dataConfiguration.dataTypes;const q = context.model(form.properties.name).asQueryable();const selectArguments = [];const model = dataConfiguration.model(form.properties.name);const attributes = model.fields;let totalResponses = 0;const summaryValues = [];const currentLocale = context.locale;const settings = form.settings && form.settings.i18n;const translations = settings && settings[currentLocale] || {};attributes.forEach((attribute) => {const key = this.findByKey(form.components, attribute.name);if (key) {attribute = Object.assign(attribute, { name: attribute.name, label: key.label });attribute.isNumber = false;attribute.legend = key.customProperties && key.customProperties.legend;if (Object.prototype.hasOwnProperty.call(dataTypes, attribute.type)) {attribute.isNumber = dataTypes[attribute.type].type === 'number';}selectArguments.push(`${attribute.name}`); // clear std,avg and responses from model attribute
attribute.std = 0;attribute.avg = 0;delete attribute.responses;if (attribute.isNumber && key.type === 'radio') {if (summaryValues.length === 0) {// add radio values from first key
summaryValues.push.apply(summaryValues, key.values);} else {//add values only if not exist
summaryValues.push.apply(summaryValues, key.values.filter((x) => {return summaryValues.findIndex((y) => {return y.value === x.value;}) < 0;}));}} // clone attribute
let attr = { ...attribute };attr.legend = translations[attr.legend] || attr.legend;attr.title = translations[attr.title] || attr.title;attr.label = translations[attr.label] || attr.label;result.push(attr);}}); // add total = 0 to summaryValues
summaryValues.map((x) => {x.total = 0;return x;});const showResults = await this.showResults(context); // check if zero values should be excluded
const ignoreZeroValues = context.getConfiguration().getSourceAt('settings/evaluation/ignoreZeroValues') || false;if (selectArguments.length && showResults === true) {const responses = await q.select.apply(q, selectArguments).where('evaluationEvent').in(evaluationEvents).silent().getAllItems();for (let i = 0; i < result.length; i++) {const qElement = result[i];qElement.responses = [];if (qElement.isNumber) {// remove zero values from responses avg and std
const sample = responses.filter((x) => {if (ignoreZeroValues) {return x[qElement.name] != 0;}return true;}); // add mean value and standard deviation
qElement.std = sample.length ? math.std(sample.map((x) => {return x[qElement.name];})) : 0;qElement.avg = sample.length ? math.mean(sample.map((x) => {return x[qElement.name];})) : 0;qElement.numberOfResponses = sample.length;summaryValues.forEach((radioValue) => {const result = responses.filter((y) => {return y[qElement.name] == radioValue.value;}).length;radioValue.total += result;totalResponses += result;});} else {qElement.responses = responses.map((x) => {return x[qElement.name];});}}}const results = {};results.responses = result;results.summaryValues = [];results.summaryValues.push.apply(results.summaryValues, summaryValues.map((x) => {x.totalResponses = totalResponses;return x;}));return results;}async showResults(context) {// this is a custom way to get permissions based on eventStatus and user groups
let showResults = true;let found = false;const eventStatusesResults = context.getConfiguration().getSourceAt('settings/evaluation/results/statuses') || [];const groupsAllowed = context.getConfiguration().getSourceAt('settings/evaluation/results/groupsAllowed') || [];if (eventStatusesResults.length > 0) {// check if eventStatus is allowed
if (groupsAllowed.length > 0) {// but first check if user belongs to allowed groups
const userName = context.interactiveUser ? context.interactiveUser.name : context.user.name;const user = await context.model('User').where('name').equal(userName).expand('groups').getItem();found = user.groups.some((r) => groupsAllowed.indexOf(r.name) >= 0);}if (!found) {const evaluationEvent = await context.model('EvaluationEvent').where('id').equal(this.getId()).select('id', 'eventStatus/alternateName as eventStatus').getItem();if (evaluationEvent == null) {throw new _common.HttpNotFoundError('The specified evaluation cannot be found');}showResults = eventStatusesResults.findIndex((x) => {return x === evaluationEvent.eventStatus;}) >= 0;}}return showResults;}findByKey(array, key) {for (let index = 0; index < array.length; index++) {const element = array[index];if (element.key === key) {return element;} else {if (element.components) {const found = this.findByKey(element.components, key);if (found) {return found;}}}}}setParentLegend(component) {if (Array.isArray(component.components)) {component.components.forEach((item) => {if (component.legend) {item.customProperties = item.customProperties || {};item.customProperties.legend = component.legend;}this.setParentLegend(item);});}}async evaluate(data) {const token = await EvaluationAccessToken.inspect(this.context, this.context.user.authenticationToken);if (token.active === false) {throw new _common.HttpTokenExpiredError('Evaluation access token has been expired or is invalid.');}if (this.id !== token.sub) {throw new _common.HttpBadRequestError('Evaluation access token is invalid');} // get evaluation form
const form = await this.getForm(); // get evaluation document
if (form == null) {throw new _common.HttpNotFoundError('Evaluation form cannot be found');} // get evaluation document
const evaluationDocument = await this.context.model(_evaluationDocumentModel.default).where('id').equal(form.properties._id).silent().getItem();if (evaluationDocument == null) {throw new _common.HttpNotFoundError('Evaluation document cannot be found');} // assign defaults
Object.assign(data, { evaluationEvent: this.id, evaluationDocument: evaluationDocument.id }); // and finally append evaluation
await this.context.model(evaluationDocument.resultType).on('after.save', (event, callback) => {const context = event.model.context;context.model(EvaluationAccessToken).where('access_token').equal(context.user && context.user.authenticationToken).silent().getItem().then((token) => {if (token == null) {throw new _common.HttpTokenExpiredError('Evaluation token is required');}return context.model(EvaluationAccessToken).silent().save({ access_token: token.access_token, expires: new Date(0), // set epoch date
used: true });}).then(() => {return callback();}).catch((err) => {_common.TraceUtils.error('An error occurred while submitting evaluation');return callback(err);});}).silent().save(data);return data;}static async generateTokens(context, generateOptions) {let items = [];if (Array.isArray(generateOptions)) {
let n = 0;
let newItems;
// eslint-disable-next-line no-unused-vars
for (let itemGenerateOptions of generateOptions) {
newItems = await EvaluationEvent.generateManyTokens(context, itemGenerateOptions);
n += newItems.length;
items.push.apply(items, newItems);
}
await context.model('EvaluationAccessToken').silent().save(items);
return n;
} else {
items = await EvaluationEvent.generateManyTokens(context, generateOptions);
await context.model('EvaluationAccessToken').silent().save(items);
return items.length;
}
}
/**
* Generates a number of access tokens for the given event
* @param {DataContext} context
* @param {{evaluationEvent: number, n: number, expires?: DateTime}} generateOptions
*/
static async generateManyTokens(context, generateOptions) {
const n = generateOptions.n;
_common.Args.check(typeof n === 'number', 'Expected a number greater than zero');
_common.Args.check(n > 0, 'The number of access tokens must be greater than zero');
let maximumGenerateTokens = _common.LangUtils.parseInt(context.getConfiguration().getSourceAt('settings/evaluation/maximumGenerateTokens'));
if (maximumGenerateTokens === 0) {
maximumGenerateTokens = 1000;
}
_common.Args.check(n < maximumGenerateTokens, `The specified number of tokens exceeded the maximum allowed (${maximumGenerateTokens})`);
const items = [];
// generate tokens
for (let index = 0; index < n; index++) {
items.push({
evaluationEvent: generateOptions.evaluationEvent,
expires: generateOptions.expires });
}
return items;
}
/**
*
* @param {import('@themost/express').ExpressDataContext} appContext
* @param students
* @param tokens
* @param tokenInfo
* @param mailTemplate
* @param subject
* @param action
* @param evaluationEvent
*/
static sendMessages(appContext, students, tokens, tokenInfo, mailTemplate, subject, action, evaluationEvent) {
const app = appContext.getApplication();
const context = app.createContext();
const user = appContext.user;
context.user = user;
let failed = [];
let succeeded = [];
let message = subject;
const transporter = appContext.getApplication().getService(_EvaluationService.EvaluationService).getMailTransporter();
// use an async function to send emails
(async () => {
for (const student of students) {
// refresh mailer
const mailer = (0, _mailer.getMailer)(context); // maybe investigate why this has to be refreshed.
// pop an access token
const access_token = tokens.pop();
// send message
await new Promise((resolve) => {
mailer.transporter(transporter).
template(mailTemplate.template).
subject(subject).
to(student.email).
send(Object.assign(evaluationEvent, {
access_token }),
(err) => {
if (err) {
try {
_common.TraceUtils.error(`SendEvaluationTokensAction', 'An error occurred while trying to send token to student email ${student.email} for evaluation event ${evaluationEvent.id}`);
_common.TraceUtils.error(err);
} catch (err1) {
// do nothing
}
failed.push(access_token);
return resolve();
}
succeeded.push(access_token);
// update successfully sent token
return context.model('EvaluationAccessToken').silent().save({
access_token: access_token, // access_token is primary
sent: true,
$state: 2 }).
then((χ) => {
return resolve();
}).catch((err) => {
_common.TraceUtils.error(err);
return resolve();
});
});
});
}
})().then(() => {
context.finalize(() => {
// create send tokens action
const sendTokensAction = {
id: action && action.id || null,
event: evaluationEvent,
numberOfStudents: students.length,
total: tokenInfo.total,
sent: succeeded.length,
failed: failed.length,
actionStatus: {
alternateName: tokenInfo.total === failed.length ? 'FailedActionStatus' : 'CompletedActionStatus' },
description: tokenInfo.total === failed.length ? context.__('Sending tokens failed for all students') : null };
// and save
return context.model('SendInstructorTokenAction').silent().save(sendTokensAction).then((item) => {
EvaluationEvent.sendSse(user, context, evaluationEvent.id, message, sendTokensAction.description ? { message: sendTokensAction.description } : null);
}).catch((err) => {
_common.TraceUtils.error(err);
});
});
}).catch((err) => {
context.finalize(() => {
// log error
_common.TraceUtils.error(err);
// update successfully sent tokens
const succeededTokens = succeeded.map((succeededToken) => {
return {
access_token: succeededToken,
sent: true,
$state: 2 };
});
return context.model('EvaluationAccessToken').silent().save(succeededTokens).then((evaluationTokens) => {
// create send tokens action
const sendTokensAction = {
id: action && action.id || null,
event: evaluationEvent,
numberOfStudents: students.length,
total: tokenInfo.total,
sent: succeeded.length,
failed: failed.length,
actionStatus: {
alternateName: tokenInfo.total === failed.length ? 'FailedActionStatus' : 'CompletedActionStatus' },
description: tokenInfo.total === failed.length ? context.__('Sending tokens failed for all students') : null };
// and save
return context.model('SendInstructorTokenAction').silent().save(sendTokensAction).then((tokenAction) => {
EvaluationEvent.sendSse(user, context, evaluationEvent.id, message, err);
}).catch((err) => {
_common.TraceUtils.error(err);
});
});
});
});
}
static sendSse(user, context, evaluationEvent, title, error) {
// try to find ServerSentEventService
const sse = context.getApplication().getService(function ServerSentEventService() {});
// only if it exists - it is not required
if (sse != null) {
if (user != null) {
try {
// create an event
const event = {
entitySet: 'EvaluationEvents',
entityType: "EvaluationEvent",
target: {
id: evaluationEvent,
message: error ? `${title} ${error.message}` : title },
status: {
success: !error },
dateCreated: new Date() };
// and send it to the user that is performing the approval (registrar scope)
sse.emit(user.name, user.authenticationScope || "qa", event);
}
catch (err) {
_common.TraceUtils.error(`An error occurred while trying to send sse event`);
_common.TraceUtils.error(err);
}
}
}
}}, (_applyDecoratedDescriptor(_class2, "getCurrent", [_dec2], Object.getOwnPropertyDescriptor(_class2, "getCurrent"), _class2), _applyDecoratedDescriptor(_class2.prototype, "getForm", [_dec3], Object.getOwnPropertyDescriptor(_class2.prototype, "getForm"), _class2.prototype), _applyDecoratedDescriptor(_class2.prototype, "getResults", [_dec4], Object.getOwnPropertyDescriptor(_class2.prototype, "getResults"), _class2.prototype), _applyDecoratedDescriptor(_class2.prototype, "getAccessTokens", [_dec5], Object.getOwnPropertyDescriptor(_class2.prototype, "getAccessTokens"), _class2.prototype), _applyDecoratedDescriptor(_class2.prototype, "getFormAttributes", [_dec6], Object.getOwnPropertyDescriptor(_class2.prototype, "getFormAttributes"), _class2.prototype), _applyDecoratedDescriptor(_class2.prototype, "getFormAttributesSummary", [_dec7], Object.getOwnPropertyDescriptor(_class2.prototype, "getFormAttributesSummary"), _class2.prototype), _applyDecoratedDescriptor(_class2.prototype, "evaluate", [_dec8, _dec9], Object.getOwnPropertyDescriptor(_class2.prototype, "evaluate"), _class2.prototype)), _class2)) || _class);
module.exports = EvaluationEvent;
//# sourceMappingURL=evaluation-event-model.js.map