diff --git a/README.md b/README.md index 437116a..acb2920 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Testing is done with mocha and can be executed using `npm test`. ## General structure -[index.ts](./src/index.ts) is exectued when starting the server. It includes all setup tasks, registers middleware, +[index.ts](./src/index.ts) is executed when starting the server. It includes all setup tasks, registers middleware, routes and error handlers. Setting the `NODE_ENV` environment variable allows starting the server either in `production`, `development` or `test` mode. diff --git a/api/api.yaml b/api/api.yaml index d85acc7..5d0ab02 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -65,6 +65,7 @@ tags: - name: /template - name: /model - name: /user + - name: /help paths: @@ -76,6 +77,7 @@ paths: - $ref: 'template.yaml' - $ref: 'model.yaml' - $ref: 'user.yaml' + - $ref: 'help.yaml' components: diff --git a/api/help.yaml b/api/help.yaml new file mode 100644 index 0000000..de36cbe --- /dev/null +++ b/api/help.yaml @@ -0,0 +1,60 @@ +/help/{key}: + parameters: + - $ref: 'api.yaml#/components/parameters/Key' + get: + summary: get help text for key + description: 'Auth: basic, levels: predict, read, write, dev, admin, depending on the set document level' + tags: + - /help + responses: + 200: + description: the required text + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Help' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + post: + summary: add/replace help text + description: 'Auth: basic, levels: dev, admin
If the given key exists, the item is replaced, + otherwise it is newly created' + tags: + - /help + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Help' + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: remove help text + description: 'Auth: basic, levels: dev, admin' + tags: + - /help + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/parameters.yaml b/api/parameters.yaml index 192b15a..a17127f 100644 --- a/api/parameters.yaml +++ b/api/parameters.yaml @@ -46,4 +46,13 @@ Group: required: true schema: type: string - example: vn \ No newline at end of file + example: vn + +Key: + name: key + description: URIComponent encoded string + in: path + required: true + schema: + type: string + example: '%2Fdocumentation%2Fdatabase' \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml index 1359d23..6816022 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -52,7 +52,8 @@ - name: filters[] description: "the filters to apply as an array of URIComponent encoded objects in the form {mode: 'eq/ne/lt/lte/gt/gte/in/nin/stringin', field: 'material.m', values: ['15']} using - encodeURIComponent(JSON.stringify({}))" + encodeURIComponent(JSON.stringify({}))
Use {mode: 'eq', field: 'condition', values: [{}]} and + {mode: 'eq', field: 'measurements', values: [null]} to filter for samples without condition or measurements" in: query schema: type: array diff --git a/api/schemas.yaml b/api/schemas.yaml index a61a819..1da048f 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -232,4 +232,14 @@ ModelItem: example: https://definma-model-test.apps.de1.bosch-iot-cloud.com/predict/model1-1 label: type: string - example: 'ml/g' \ No newline at end of file + example: 'ml/g' + +Help: + properties: + text: + type: string + example: This page documents the database. + level: + type: string + description: can be also null to allow access without authorization + example: read \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 2e8592d..b82151d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -85,7 +85,7 @@ export default class db { cron.schedule('0 0 * * *', () => { ChangelogModel.deleteMany({_id: {$lt: // id from time Math.floor(new Date().getTime() / 1000 - changelogKeepDays * 24 * 60 * 60).toString(16) + '0000000000000000' - }}).log({method: 'scheduled changelog delete', url: '', authDetails: {}}).lean().exec(err => { + }}).lean().exec(err => { if (err) console.error(err); }); }); @@ -149,7 +149,6 @@ export default class db { // changelog entry, expects (req, this (from query helper)) or (req, collection, conditions, data) static log(req, thisOrCollection, conditions = null, data = null) { if (! (conditions || data)) { // (req, this) - console.log(11); data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {} // replace keys with a leading $ Object.keys(data).forEach(key => { @@ -158,7 +157,6 @@ export default class db { delete data[key]; } }); - console.log(thisOrCollection._conditions); new ChangelogModel(this.logEscape(_.cloneDeep({ action: req.method + ' ' + req.url, collection_name: thisOrCollection._collection.collectionName, diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts index d7774d7..5e8ae03 100644 --- a/src/helpers/csv.ts +++ b/src/helpers/csv.ts @@ -1,7 +1,7 @@ import {parseAsync} from 'json2csv'; import flatten from './flatten'; -export default function csv(input: any[], f: (err, data) => void) { +export default function csv(input: any[], f: (err, data) => void) { // parse JSON to CSV parseAsync(input.map(e => flatten(e)), {includeEmptyRows: true}) .then(csv => f(null, csv)) .catch(err => f(err, null)); diff --git a/src/helpers/flatten.ts b/src/helpers/flatten.ts index ed54da9..d28bd64 100644 --- a/src/helpers/flatten.ts +++ b/src/helpers/flatten.ts @@ -3,10 +3,10 @@ import globals from '../globals'; export default function flatten (data, keepArray = false) { // flatten object: {a: {b: true}} -> {a.b: true} const result = {}; function recurse (cur, prop) { - if (Object(cur) !== cur || Object.keys(cur).length === 0) { + if (Object(cur) !== cur || Object.keys(cur).length === 0) { // simple value result[prop] = cur; } - else if (prop === `${globals.spectrum.spectrum}.${globals.spectrum.dpt}`) { + else if (prop === `${globals.spectrum.spectrum}.${globals.spectrum.dpt}`) { // convert spectrum for ML result[prop + '.labels'] = cur.map(e => parseFloat(e[0])); result[prop + '.values'] = cur.map(e => parseFloat(e[1])); } @@ -27,7 +27,7 @@ export default function flatten (data, keepArray = false) { // flatten object: } } } - else { + else { // object let isEmpty = true; for (let p in cur) { isEmpty = false; diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts index a76b287..2c0ab57 100644 --- a/src/helpers/mail.ts +++ b/src/helpers/mail.ts @@ -4,10 +4,10 @@ import axios from 'axios'; export default class Mail{ - static readonly address = 'definma@bosch-iot.com'; - static uri: string; - static auth = {username: '', password: ''}; - static mailPass: string; + static readonly address = 'definma@bosch-iot.com'; // email address + static uri: string; // mail API URI + static auth = {username: '', password: ''}; // mail API credentials + static mailPass: string; // mail API generates password static init() { if (process.env.NODE_ENV === 'production') { // only send mails in production @@ -51,14 +51,14 @@ export default class Mail{ }).then(() => { // init done successfully console.info('Mail service established successfully'); this.send('lukas.veit@bosch.com', 'Mail Service started', new Date().toString()); - }).catch(err => { // anywhere an error occurred + }).catch(err => { // somewhere an error occurred console.error(`Mail init error: ${err.request.method} ${err.request.path}: ${err.response.status}`, err.response.data); }); } } - static send (mailAddress, subject, content, f: (x?) => void = () => {}) { // callback, executed empty or with error + static send (mailAddress, subject, content, f: (x?) => void = () => {}) { // callback executed empty or with error if (process.env.NODE_ENV === 'production') { // only send mails in production axios({ method: 'post', diff --git a/src/index.ts b/src/index.ts index a1c7417..f64e847 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,9 @@ app.use(require('./helpers/authorize')); // handle authentication // redirect /api routes for Angular proxy in development if (process.env.NODE_ENV !== 'production') { app.use('/api/:url([^]+)', (req, res) => { + if (/help\//.test(req.params.url)) { // encode URI again for help route + req.params.url = 'help/' + encodeURIComponent(req.params.url.replace('help/', '')); + } req.url = '/' + req.params.url; app.handle(req, res); }); @@ -114,6 +117,7 @@ app.use('/', require('./routes/measurement')); app.use('/', require('./routes/template')); app.use('/', require('./routes/model')); app.use('/', require('./routes/user')); +app.use('/', require('./routes/help')); // static files app.use('/static', express.static('static')); diff --git a/src/models/help.ts b/src/models/help.ts new file mode 100644 index 0000000..a04ae33 --- /dev/null +++ b/src/models/help.ts @@ -0,0 +1,16 @@ +import db from '../db'; +import mongoose from 'mongoose'; + +const HelpSchema = new mongoose.Schema({ + key: {type: String, index: {unique: true}}, + level: String, + text: String +}, {minimize: false}); + +// changelog query helper +HelpSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('help', HelpSchema); \ No newline at end of file diff --git a/src/routes/help.spec.ts b/src/routes/help.spec.ts new file mode 100644 index 0000000..590d47b --- /dev/null +++ b/src/routes/help.spec.ts @@ -0,0 +1,184 @@ +import should from 'should/as-function'; +import TestHelper from "../test/helper"; +import HelpModel from '../models/help'; + + +describe('/help', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + describe('GET /help/{key}', () => { + it('returns the required text', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fsamples', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {text: 'Samples help', level: 'read'} + }); + }); + it('returns the required text without authorization if allowed', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fdocumentation', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {text: 'Documentation help', level: 'none'} + }); + }); + it('returns 404 for an invalid key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/documentation/database', + httpStatus: 404 + }); + }); + it('returns 404 for an unknown key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/xxx', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('returns 403 for a text with a higher level than given', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fmodels', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fsamples', + auth: {api: 'janedoe'}, + httpStatus: 401, + }); + }); + it('rejects an unauthorized request if a level is given', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fsamples', + httpStatus: 401 + }); + }); + }); + + describe('POST /help/{key}', () => { + it('changes the required text', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {text: 'New samples help', level: 'write'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + HelpModel.find({key: '/samples'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'key', 'text', 'level'); + should(data[0]).property('key', '/samples'); + should(data[0]).property('text', 'New samples help'); + should(data[0]).property('level', 'write'); + done(); + }); + }); + }); + it('saves a new text', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fmaterials', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {text: 'Materials help', level: 'dev'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + HelpModel.find({key: '/materials'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'key', 'text', 'level', '__v'); + should(data[0]).property('key', '/materials'); + should(data[0]).property('text', 'Materials help'); + should(data[0]).property('level', 'dev'); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + auth: {key: 'admin'}, + httpStatus: 401, + req: {text: 'New samples help', level: 'write'} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {text: 'New samples help', level: 'write'} + }); + }); + it('rejects an unauthorized request', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + httpStatus: 401, + req: {text: 'New samples help', level: 'write'} + }); + }); + }); + + describe('DELETE /help/{key}', () => { + it('deletes the required entry', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + HelpModel.find({key: '/materials'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an unauthorized request', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + httpStatus: 401 + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/help.ts b/src/routes/help.ts new file mode 100644 index 0000000..3a2cb51 --- /dev/null +++ b/src/routes/help.ts @@ -0,0 +1,55 @@ +import express from 'express'; +import HelpModel from '../models/help'; +import HelpValidate from './validate/help'; +import res400 from './validate/res400'; +import globals from '../globals'; + +const router = express.Router(); + +router.get('/help/:key', (req, res, next) => { + const {error: paramError, value: key} = HelpValidate.params(req.params); + if (paramError) return res400(paramError, res); + + HelpModel.findOne(key).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (data.level !== 'none') { // check level + if (!req.auth(res, + Object.values(globals.levels).slice(Object.values(globals.levels).findIndex(e => e === data.level)) + , 'basic')) return; + } + res.json(HelpValidate.output(data)); + }) +}); + +router.post('/help/:key', (req, res, next) => { + if (!req.auth(res, ['dev', 'admin'], 'basic')) return; + const {error: paramError, value: key} = HelpValidate.params(req.params); + if (paramError) return res400(paramError, res); + const {error, value: help} = HelpValidate.input(req.body); + if (error) return res400(error, res); + + HelpModel.findOneAndUpdate(key, help, {upsert: true}).log(req).lean().exec(err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); +}); + +router.delete('/help/:key', (req, res, next) => { + if (!req.auth(res, ['dev', 'admin'], 'basic')) return; + const {error: paramError, value: key} = HelpValidate.params(req.params); + if (paramError) return res400(paramError, res); + + HelpModel.findOneAndDelete(key).log(req).lean().exec((err, data) => { + if (err) return next(err); + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/material.ts b/src/routes/material.ts index a75f73d..3996e30 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -25,21 +25,7 @@ router.get('/materials', (req, res, next) => { MaterialValidate.query(req.query, ['dev', 'admin'].indexOf(req.authDetails.level) >= 0); if (error) return res400(error, res); - let conditions; - - if (filters.hasOwnProperty('status')) { - if(filters.status === 'all') { - conditions = {$or: [{status: globals.status.val}, {status: globals.status.new}]} - } - else { - conditions = {status: filters.status}; - } - } - else { // default - conditions = {status: globals.status.val}; - } - - MaterialModel.find(conditions).sort({name: 1}).populate('group_id').populate('supplier_id') + MaterialModel.find(filters).sort({name: 1}).populate('group_id').populate('supplier_id') .lean().exec((err, data) => { if (err) return next(err); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 16107c9..9a53e29 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -440,6 +440,48 @@ describe('/sample', () => { done(); }); }); + it('filters for empty conditions', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status[]=new&status[]=validated&fields[]=number&fields[]=condition&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22condition%22%2C%22values%22%3A%5B%7B%7D%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf( + json.collections.samples + .filter(e => e.status !== 'deleted') + .filter(e => Object.keys(e.condition).length === 0) + .length + ); + should(res.body).matchEach(sample => { + should(sample.condition).be.eql({}); + }); + done(); + }); + }); + it('filters for samples without measurements', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status[]=new&status[]=validated&fields[]=number&fields[]=_id&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22measurements%22%2C%22values%22%3A%5Bnull%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf( + json.collections.samples + .filter(e => e.status !== 'deleted') + .filter(e => !json.collections.measurements.find(el => el.sample_id.toString() === e._id.toString())) + .length + ); + should(res.body).matchEach(sample => { + should(json.collections.measurements.find(el => el.sample_id.toString() === sample._id)).be.eql(undefined); + }); + done(); + }); + }); it('returns comment fields', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 4726bd3..71e84e9 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -22,16 +22,12 @@ import globals from '../globals'; const router = express.Router(); -// TODO: do not use streaming for spectrum filenames - router.get('/samples', async (req, res, next) => { if (!req.auth(res, ['read', 'write', 'dev', 'admin'], 'all')) return; const {error, value: filters} = SampleValidate.query(req.query, ['dev', 'admin'].indexOf(req.authDetails.level) >= 0); - console.log(error); if (error) return res400(error, res); - console.log(filters.filters); // spectral data and csv not allowed for read/write users if ((filters.fields.find(e => e.indexOf('.' + globals.spectrum.dpt) >= 0) || filters.output !== 'json') && @@ -219,6 +215,15 @@ router.get('/samples', async (req, res, next) => { } } + if (sortFilterKeys.find(e => e === 'measurements')) { // filter for samples without measurements + queryPtr.push({$lookup: { + from: 'measurements', let: {sId: '$_id'}, + pipeline: [{$match:{$expr:{$and:[{$eq:['$sample_id','$$sId']}]}}}, {$project: {_id: true}}], + as: 'measurementCount' + }}, + {$match: {measurementCount: {$size: 0}}} + ); + } const measurementFilterFields = _.uniq(sortFilterKeys.filter(e => /measurements\./.test(e)) .map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters if (sortFilterKeys.find(e => /measurements\./.test(e))) { // add measurement fields @@ -235,7 +240,7 @@ router.get('/samples', async (req, res, next) => { ]; if (measurementFilterFields.indexOf(globals.spectrum.spectrum) >= 0) { // filter out dpts pipeline.push( - {$project: {'values.device': true, measurement_template: true}}, + {$project: {['values.' + globals.spectrum.dpt]: false}}, {$addFields: {'values._id': '$_id'}} ); } @@ -379,7 +384,6 @@ router.get('/samples', async (req, res, next) => { projection._id = false; } queryPtr.push({$project: projection}); - console.log(JSON.stringify(query)); // use streaming when including spectrum files if (!fieldsToAdd.find(e => e.indexOf(globals.spectrum.spectrum + '.' + globals.spectrum.dpt) >= 0)) { collection.aggregate(query).allowDiskUse(true).exec((err, data) => { @@ -678,11 +682,10 @@ async function numberGenerate (sample, req, res, next) { const sampleData = await SampleModel .aggregate([ {$match: {number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}}, - // {$addFields: {number2: {$toDecimal: {$arrayElemAt: [{$split: [{$arrayElemAt: - // [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}}}}, // not working with MongoDb 3.6 {$addFields: {sortNumber: {$let: { vars: {tmp: {$concat: ['000000000000000000000000000000', - {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}]}}, + {$arrayElemAt: [{$split: + [{$arrayElemAt: [{$split: ['$number', req.authDetails.location]}, 1]}, '_']}, 0]}]}}, in: {$substrCP: ['$$tmp', {$subtract: [{$strLenCP: '$$tmp'}, 30]}, {$strLenCP: '$$tmp'}]} }}}}, {$sort: {sortNumber: -1}}, diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 16da61d..d7e4b79 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -1,7 +1,11 @@ import should from 'should/as-function'; import _ from 'lodash'; import TemplateConditionModel from '../models/condition_template'; +import SampleModel from '../models/sample'; +import MeasurementModel from '../models/measurement'; +import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; +import mongoose from 'mongoose'; describe('/template', () => { @@ -90,7 +94,7 @@ describe('/template', () => { }); }); - describe('PUT /template/condition/{name}', () => { + describe('PUT /template/condition/{id}', () => { it('returns the right condition template', done => { TestHelper.request(server, done, { method: 'put', @@ -145,6 +149,24 @@ describe('/template', () => { }); }); }); + it('renames all occurrences instead of creating a new version when only the parameter name is changed', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, req: {name: 'heat treatment', parameters: [{name: 'treatmentMaterial', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10, required: true}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat treatment', version: 1, first_id: '200000000000000000000001', parameters: [{name: 'treatmentMaterial', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10, required: true}}]}); + SampleModel.find({'condition.condition_template': mongoose.Types.ObjectId('200000000000000000000001')}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).matchEach(sample => { + should(sample.condition).have.only.keys('treatmentMaterial', 'weeks', 'condition_template'); + }); + done(); + }); + }); + }); it('creates a changelog', done => { TestHelper.request(server, done, { method: 'put', @@ -161,7 +183,7 @@ describe('/template', () => { } }); }); - it('allows changing only one property', done => { + it('does not increase the version on name change', done => { TestHelper.request(server, done, { method: 'put', url: '/template/condition/200000000000000000000001', @@ -175,7 +197,7 @@ describe('/template', () => { should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); should(data.first_id.toString()).be.eql('200000000000000000000001'); should(data).have.property('name', 'heat aging'); - should(data).have.property('version', 2); + should(data).have.property('version', 1); should(data).have.property('parameters').have.lengthOf(2); should(data.parameters[0]).have.property('name', 'material'); should(data.parameters[1]).have.property('name', 'weeks'); @@ -183,6 +205,28 @@ describe('/template', () => { }); }); }); + it('does not increase the version on name change when property ranges stayed the same', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'duration', range: {min: 1, max: 10, required: true}}]} + }).end((err, res) => { + if (err) return done(err); + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('200000000000000000000001'); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 1); + should(data).have.property('parameters').have.lengthOf(2); + should(data.parameters[0]).have.property('name', 'material'); + should(data.parameters[1]).have.property('name', 'duration'); + done(); + }); + }); + }); it('supports values ranges', done => { TestHelper.request(server, done, { method: 'put', @@ -526,6 +570,26 @@ describe('/template', () => { }); }); }); + describe('PUT /template/measurement/{id}', () => { + it('renames all occurrences instead of creating a new version when only the parameter name is changed', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, req: {name: 'spectrum', parameters: [{name: 'spectrumValues', range: {type: 'array'}}, {name: 'device', range: {}}, {name: 'filename', range: {}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '300000000000000000000001', name: 'spectrum', version: 1, first_id: '300000000000000000000001', parameters: [{name: 'spectrumValues', range: {type: 'array'}}, {name: 'device', range: {}}, {name: 'filename', range: {}}]}); + MeasurementModel.find({'measurement_template': mongoose.Types.ObjectId('300000000000000000000001')}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).matchEach(measurement => { + should(measurement.values).have.only.keys('spectrumValues', 'device', 'filename'); + }); + done(); + }); + }); + }); + }); // other methods should be covered by condition tests }); @@ -571,6 +635,27 @@ describe('/template', () => { }); }); }); + describe('PUT /template/material/{id}', () => { + it('renames all occurrences instead of creating a new version when only the parameter name is changed', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/material/130000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'plastic', parameters: [ {name: 'glassfiber', range: {min: 0, max: 100, required: true}}, {name: 'carbonfiber', range: {min: 0, max: 100, required: true}}, {name: 'mineral', range: {min: 0, max: 100, required: true}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '130000000000000000000003', name: 'plastic', version: 2, first_id: '130000000000000000000001', parameters: [ {name: 'glassfiber', range: {min: 0, max: 100, required: true}}, {name: 'carbonfiber', range: {min: 0, max: 100, required: true}}, {name: 'mineral', range: {min: 0, max: 100, required: true}}]}); + MaterialModel.find({'properties': mongoose.Types.ObjectId('130000000000000000000003')}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).matchEach(material => { + should(material.parameters).have.only.keys('glassfiber', 'carbonfiber', 'mineral', 'material_template'); + }); + done(); + }); + }); + }); + }); // other methods should be covered by condition tests }); }); \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts index ad73a8c..8eeffdd 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -5,6 +5,9 @@ import TemplateValidate from './validate/template'; import ConditionTemplateModel from '../models/condition_template'; import MeasurementTemplateModel from '../models/measurement_template'; import MaterialTemplateModel from '../models/material_template'; +import SampleModel from '../models/sample'; +import MaterialModel from '../models/material'; +import MeasurementModel from '../models/measurement'; import res400 from './validate/res400'; import IdValidate from './validate/id'; import mongoose from "mongoose"; @@ -61,13 +64,66 @@ router.put('/template/:collection(measurement|condition|material)/' + IdValidate } if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed - template.version = templateData.version + 1; // increase version - // save new template, fill with old properties - await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { - if (err) next (err); - db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); - res.json(TemplateValidate.output(data.toObject())); - }); + if (!template.parameters || _.isEqual(templateData.parameters, template.parameters)) { // only name was changed + model(req).findByIdAndUpdate(req.params.id, {name: template.name}, {new: true}) + .log(req).lean().exec((err, data) => { + if (err) next (err); + res.json(TemplateValidate.output(data)); + }); + } + else if (template.parameters.filter((e, i) => _.isEqual(e.range, templateData.parameters[i].range)).length + === templateData.parameters.length) { // only names changed + const changedParameterNames = template.parameters.map((e, i) => ( // list of new names + {name: e.name, index: i, oldName: templateData.parameters[i].name} + )).filter(e => e.name !== e.oldName); + + // custom mappings for different collections + let targetModel; // model of the collection where the template is used + let pathPrefix; // path to the parameters in use + let templatePath; // complete path of the template property + switch (req.params.collection) { + case 'condition': + targetModel = SampleModel; + pathPrefix = 'condition.'; + templatePath = 'condition.condition_template'; + break; + case 'measurement': + targetModel = MeasurementModel; + pathPrefix = 'values.'; + templatePath = 'measurement_template'; + break; + case 'material': + targetModel = MaterialModel; + pathPrefix = 'properties.'; + templatePath = 'properties.material_template'; + break; + } + + targetModel.updateMany({[templatePath]: mongoose.Types.ObjectId(templateData._id)}, + {$rename: + changedParameterNames.reduce((s, e) => {s[pathPrefix + e.oldName] = pathPrefix + e.name; return s;}, {}) + }) .log(req).lean().exec(err => { + if (err) return next(err); + model(req).findByIdAndUpdate(req.params.id, + {$set: + changedParameterNames.reduce( + (s, e) => {s[`parameters.${e.index}.name`] = e.name; return s;}, {name: template.name} + ), + },{new: true}).log(req).lean().exec((err, data) => { + if (err) next (err); + res.json(TemplateValidate.output(data)); + }); + }); + } + else { + template.version = templateData.version + 1; // increase version + // save new template, fill with old properties + await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { + if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); + res.json(TemplateValidate.output(data.toObject())); + }); + } } else { res.json(TemplateValidate.output(templateData)); diff --git a/src/routes/validate/help.ts b/src/routes/validate/help.ts new file mode 100644 index 0000000..6dcf64a --- /dev/null +++ b/src/routes/validate/help.ts @@ -0,0 +1,34 @@ +import Joi from 'joi'; +import globals from '../../globals'; + +export default class HelpValidate { + private static help = { + text: Joi.string() + .allow('') + .max(8192), + + level: Joi.string() + .valid('none', ...Object.values(globals.levels)) + } + + static input (data) { + return Joi.object({ + text: this.help.text.required(), + level: this.help.level.required() + }).validate(data); + } + + static output (data) { + const {value, error} = Joi.object({ + text: this.help.text, + level: this.help.level + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static params(data) { + return Joi.object({ + key: Joi.string().min(1).max(128) + }).validate(data); + } +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 54f7bb4..efaedf1 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -98,6 +98,7 @@ export default class SampleValidate { 'user_id', 'material._id', 'material.numbers', + 'measurements', `measurements.${globals.spectrum.spectrum}.${globals.spectrum.dpt}`, ]; @@ -198,7 +199,6 @@ export default class SampleValidate { data.filters[i] = decodeURIComponent(data.filters[i]); } catch (ignore) {} - console.log(data.filters[i]); data.filters[i] = JSON.parse(data.filters[i]); data.filters[i].values = data.filters[i].values.map(e => { // validate filter values if (e === null) { // null values are always allowed @@ -226,6 +226,12 @@ export default class SampleValidate { }); field = 'value'; } + else if (field === 'measurements') { + validator = Joi.object({ + value: Joi.object({}).allow(null).disallow({}) + }); + field = 'value'; + } else if (field === 'notes.comment') { field = 'comment'; validator = this.sample.notes diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 4ac78d4..c9288d7 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -75,7 +75,7 @@ export default class UserValidate { // validate input for user }).validate(data); } else { - return{error: 'No parameter specified!', value: {}}; + return {error: 'No parameter specified!', value: {}}; } } diff --git a/src/test/db.json b/src/test/db.json index f6118d2..ea276da 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -893,6 +893,26 @@ "user_id" : {"$oid": "000000000000000000000003"}, "__v" : 0 } + ], + "helps": [ + { + "_id": {"$oid":"150000000000000000000001"}, + "key": "/documentation", + "text": "Documentation help", + "level": "none" + }, + { + "_id": {"$oid":"150000000000000000000002"}, + "key": "/samples", + "text": "Samples help", + "level": "read" + }, + { + "_id": {"$oid":"150000000000000000000003"}, + "key": "/models", + "text": "Models help", + "level": "dev" + } ] } } \ No newline at end of file