@universis/evaluations
Version:
Universis evaluations library
709 lines (683 loc) • 30.7 kB
JavaScript
import {
HttpNotFoundError, HttpTokenExpiredError, HttpServerError,
TraceUtils,
HttpBadRequestError,
Args,
LangUtils, DataError, DataNotFoundError
} from '@themost/common';
import {
DataConfigurationStrategy,
DataContext,
DataObject,
EdmMapping,
EdmType,
ODataModelBuilder
} from '@themost/data';
import { HttpContext } from '@themost/web';
import { JsonFormInspector } from '@themost/form-inspector';
import EvaluationDocument from './evaluation-document-model';
const EvaluationAccessToken = require('./evaluation-access-token-model');
import {lt} from 'semver';
import util from "util";
import fs from "fs";
import moment from "moment";
import {getMailer} from "@themost/mailer";
import {EvaluationService} from "../EvaluationService";
const math = require('mathjs');
/**
* @class
*/
.entityType('EvaluationEvent')
class EvaluationEvent extends DataObject {
/**
* @constructor
*/
constructor() {
super();
}
/**
* Get current evaluation based on the evaluation key contained in Evaluation-Key HTTP header
* @param {HttpContext} context
*/
.func('Current', 'EvaluationEvent')
static async getCurrent(context) {
// get evaluation key
const token = await EvaluationAccessToken.inspect(context, context.user && context.user.authenticationToken);
if (token.active === false) {
throw new 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 HttpNotFoundError('The specified evaluation cannot be found');
}
if (evaluationEvent.eventStatus )
{
if (evaluationEvent.eventStatus.alternateName!=='EventOpened') {
throw new 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 DataError('E_NOT_STARTED',context.__('Evaluation period for this course has not been started yet'));
}
if (currentDate > endDate) {
throw new 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;
}
.func('form', 'Object')
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.promisify(fs.readFile);
let content = await readFileAsync(formPath);
if (content == null) {
throw new 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 = lt(model.version, form.version)
}
if (shouldUpgrade) {
// load for inspector
const inspector = new 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(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(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) {
TraceUtils.error(`Error while decoding evaluation form ${form.id} content.`);
TraceUtils.error(err);
throw new HttpServerError('An error occurred while decoding evaluation form.');
}
}
.func('Results', EdmType.CollectionOf('Object'))
async getResults() {
const form = await this.getForm();
return this.context.model(form.properties.name).where('evaluationEvent').equal(this.id).prepare();
}
.func('Tokens', EdmType.CollectionOf('EvaluationAccessToken'))
async getAccessTokens() {
return this.context.model('EvaluationAccessToken').where('evaluationEvent').equal(this.id).prepare();
}
.func('FormAttributes', EdmType.CollectionOf('Object'))
async getFormAttributes() {
/**
* @type {DataContext|*}
*/
const context = this.context;
const results = await this.getFormResults (context,this.getId(),null);
return results.responses;
}
.func('FormAttributesSummary', EdmType.CollectionOf('Object'))
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 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 DataError('Evaluation events not supplied.');
}
const form = await this.getForm();
this.setParentLegend(form);
// get model attributes
/**
* @type {DataConfigurationStrategy|*}
*/
const dataConfiguration = context.getApplication()
.getConfiguration().getStrategy(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 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);
});
}
}
.param('data', 'Object', false, true)
.action('Evaluate', 'Object')
async evaluate(data) {
const token = await EvaluationAccessToken.inspect(this.context, this.context.user.authenticationToken);
if (token.active === false) {
throw new HttpTokenExpiredError('Evaluation access token has been expired or is invalid.');
}
if (this.id !== token.sub) {
throw new HttpBadRequestError('Evaluation access token is invalid');
}
// get evaluation form
const form = await this.getForm();
// get evaluation document
if (form == null) {
throw new HttpNotFoundError('Evaluation form cannot be found');
}
// get evaluation document
const evaluationDocument = await this.context.model(EvaluationDocument)
.where('id').equal(form.properties._id)
.silent().getItem();
if (evaluationDocument == null) {
throw new 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 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) => {
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;
Args.check(typeof n === 'number', 'Expected a number greater than zero');
Args.check(n > 0, 'The number of access tokens must be greater than zero');
let maximumGenerateTokens = LangUtils.parseInt(context.getConfiguration().getSourceAt('settings/evaluation/maximumGenerateTokens'));
if (maximumGenerateTokens === 0) {
maximumGenerateTokens = 1000;
}
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).getMailTransporter();
// use an async function to send emails
(async () => {
for (const student of students) {
// refresh mailer
const 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 {
TraceUtils.error(`SendEvaluationTokensAction', 'An error occurred while trying to send token to student email ${student.email} for evaluation event ${evaluationEvent.id}`);
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 => {
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 => {
TraceUtils.error(err);
});
});
}).catch(err => {
context.finalize(() => {
// log error
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 => {
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){
TraceUtils.error(`An error occurred while trying to send sse event`);
TraceUtils.error(err);
}
}
}
}
}
module.exports = EvaluationEvent;