UNPKG

@universis/evaluations

Version:

Universis evaluations library

709 lines (683 loc) 30.7 kB
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 */ @EdmMapping.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 */ @EdmMapping.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; } @EdmMapping.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.'); } } @EdmMapping.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(); } @EdmMapping.func('Tokens', EdmType.CollectionOf('EvaluationAccessToken')) async getAccessTokens() { return this.context.model('EvaluationAccessToken').where('evaluationEvent').equal(this.id).prepare(); } @EdmMapping.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; } @EdmMapping.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); }); } } @EdmMapping.param('data', 'Object', false, true) @EdmMapping.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;