Archived
2

Merge pull request #44 in ~VLE2FE/definma-api from develop to master

* commit '5744162220b9eb1a421c71515c92012d19e00828':
  code improvements
  implemented /help route
  Implemented filters for no condition or measurement
  Implemented new template change behaviour
This commit is contained in:
Veit Lukas (PEA4-Fe) 2020-09-03 16:27:59 +02:00
commit c8a6209e6c
23 changed files with 624 additions and 53 deletions

View File

@ -16,7 +16,7 @@ Testing is done with mocha and can be executed using `npm test`.
## General structure ## 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 routes and error handlers. Setting the `NODE_ENV` environment variable allows starting the server either in
`production`, `development` or `test` mode. `production`, `development` or `test` mode.

View File

@ -65,6 +65,7 @@ tags:
- name: /template - name: /template
- name: /model - name: /model
- name: /user - name: /user
- name: /help
paths: paths:
@ -76,6 +77,7 @@ paths:
- $ref: 'template.yaml' - $ref: 'template.yaml'
- $ref: 'model.yaml' - $ref: 'model.yaml'
- $ref: 'user.yaml' - $ref: 'user.yaml'
- $ref: 'help.yaml'
components: components:

60
api/help.yaml Normal file
View File

@ -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 <br> 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'

View File

@ -46,4 +46,13 @@ Group:
required: true required: true
schema: schema:
type: string type: string
example: vn example: vn
Key:
name: key
description: URIComponent encoded string
in: path
required: true
schema:
type: string
example: '%2Fdocumentation%2Fdatabase'

View File

@ -52,7 +52,8 @@
- name: filters[] - name: filters[]
description: "the filters to apply as an array of URIComponent encoded objects in the form {mode: 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 'eq/ne/lt/lte/gt/gte/in/nin/stringin', field: 'material.m', values: ['15']} using
encodeURIComponent(JSON.stringify({}))" encodeURIComponent(JSON.stringify({})) <br>Use {mode: 'eq', field: 'condition', values: [{}]} and
{mode: 'eq', field: 'measurements', values: [null]} to filter for samples without condition or measurements"
in: query in: query
schema: schema:
type: array type: array

View File

@ -232,4 +232,14 @@ ModelItem:
example: https://definma-model-test.apps.de1.bosch-iot-cloud.com/predict/model1-1 example: https://definma-model-test.apps.de1.bosch-iot-cloud.com/predict/model1-1
label: label:
type: string type: string
example: 'ml/g' 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

View File

@ -85,7 +85,7 @@ export default class db {
cron.schedule('0 0 * * *', () => { cron.schedule('0 0 * * *', () => {
ChangelogModel.deleteMany({_id: {$lt: // id from time ChangelogModel.deleteMany({_id: {$lt: // id from time
Math.floor(new Date().getTime() / 1000 - changelogKeepDays * 24 * 60 * 60).toString(16) + '0000000000000000' 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); 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) // changelog entry, expects (req, this (from query helper)) or (req, collection, conditions, data)
static log(req, thisOrCollection, conditions = null, data = null) { static log(req, thisOrCollection, conditions = null, data = null) {
if (! (conditions || data)) { // (req, this) if (! (conditions || data)) { // (req, this)
console.log(11);
data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {} data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {}
// replace keys with a leading $ // replace keys with a leading $
Object.keys(data).forEach(key => { Object.keys(data).forEach(key => {
@ -158,7 +157,6 @@ export default class db {
delete data[key]; delete data[key];
} }
}); });
console.log(thisOrCollection._conditions);
new ChangelogModel(this.logEscape(_.cloneDeep({ new ChangelogModel(this.logEscape(_.cloneDeep({
action: req.method + ' ' + req.url, action: req.method + ' ' + req.url,
collection_name: thisOrCollection._collection.collectionName, collection_name: thisOrCollection._collection.collectionName,

View File

@ -1,7 +1,7 @@
import {parseAsync} from 'json2csv'; import {parseAsync} from 'json2csv';
import flatten from './flatten'; 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}) parseAsync(input.map(e => flatten(e)), {includeEmptyRows: true})
.then(csv => f(null, csv)) .then(csv => f(null, csv))
.catch(err => f(err, null)); .catch(err => f(err, null));

View File

@ -3,10 +3,10 @@ import globals from '../globals';
export default function flatten (data, keepArray = false) { // flatten object: {a: {b: true}} -> {a.b: true} export default function flatten (data, keepArray = false) { // flatten object: {a: {b: true}} -> {a.b: true}
const result = {}; const result = {};
function recurse (cur, prop) { 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; 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 + '.labels'] = cur.map(e => parseFloat(e[0]));
result[prop + '.values'] = cur.map(e => parseFloat(e[1])); 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; let isEmpty = true;
for (let p in cur) { for (let p in cur) {
isEmpty = false; isEmpty = false;

View File

@ -4,10 +4,10 @@ import axios from 'axios';
export default class Mail{ export default class Mail{
static readonly address = 'definma@bosch-iot.com'; static readonly address = 'definma@bosch-iot.com'; // email address
static uri: string; static uri: string; // mail API URI
static auth = {username: '', password: ''}; static auth = {username: '', password: ''}; // mail API credentials
static mailPass: string; static mailPass: string; // mail API generates password
static init() { static init() {
if (process.env.NODE_ENV === 'production') { // only send mails in production if (process.env.NODE_ENV === 'production') { // only send mails in production
@ -51,14 +51,14 @@ export default class Mail{
}).then(() => { // init done successfully }).then(() => { // init done successfully
console.info('Mail service established successfully'); console.info('Mail service established successfully');
this.send('lukas.veit@bosch.com', 'Mail Service started', new Date().toString()); 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}`, console.error(`Mail init error: ${err.request.method} ${err.request.path}: ${err.response.status}`,
err.response.data); 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 if (process.env.NODE_ENV === 'production') { // only send mails in production
axios({ axios({
method: 'post', method: 'post',

View File

@ -100,6 +100,9 @@ app.use(require('./helpers/authorize')); // handle authentication
// redirect /api routes for Angular proxy in development // redirect /api routes for Angular proxy in development
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
app.use('/api/:url([^]+)', (req, res) => { 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; req.url = '/' + req.params.url;
app.handle(req, res); app.handle(req, res);
}); });
@ -114,6 +117,7 @@ app.use('/', require('./routes/measurement'));
app.use('/', require('./routes/template')); app.use('/', require('./routes/template'));
app.use('/', require('./routes/model')); app.use('/', require('./routes/model'));
app.use('/', require('./routes/user')); app.use('/', require('./routes/user'));
app.use('/', require('./routes/help'));
// static files // static files
app.use('/static', express.static('static')); app.use('/static', express.static('static'));

16
src/models/help.ts Normal file
View File

@ -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 <Q extends mongoose.DocumentQuery<any, any>> (req) {
db.log(req, this);
return this;
}
export default mongoose.model<any, mongoose.Model<any, any>>('help', HelpSchema);

184
src/routes/help.spec.ts Normal file
View File

@ -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
});
});
});
});

55
src/routes/help.ts Normal file
View File

@ -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;

View File

@ -25,21 +25,7 @@ router.get('/materials', (req, res, next) => {
MaterialValidate.query(req.query, ['dev', 'admin'].indexOf(req.authDetails.level) >= 0); MaterialValidate.query(req.query, ['dev', 'admin'].indexOf(req.authDetails.level) >= 0);
if (error) return res400(error, res); if (error) return res400(error, res);
let conditions; MaterialModel.find(filters).sort({name: 1}).populate('group_id').populate('supplier_id')
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')
.lean().exec((err, data) => { .lean().exec((err, data) => {
if (err) return next(err); if (err) return next(err);

View File

@ -440,6 +440,48 @@ describe('/sample', () => {
done(); 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 => { it('returns comment fields', done => {
TestHelper.request(server, done, { TestHelper.request(server, done, {
method: 'get', method: 'get',

View File

@ -22,16 +22,12 @@ import globals from '../globals';
const router = express.Router(); const router = express.Router();
// TODO: do not use streaming for spectrum filenames
router.get('/samples', async (req, res, next) => { router.get('/samples', async (req, res, next) => {
if (!req.auth(res, ['read', 'write', 'dev', 'admin'], 'all')) return; 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); const {error, value: filters} = SampleValidate.query(req.query, ['dev', 'admin'].indexOf(req.authDetails.level) >= 0);
console.log(error);
if (error) return res400(error, res); if (error) return res400(error, res);
console.log(filters.filters);
// spectral data and csv not allowed for read/write users // spectral data and csv not allowed for read/write users
if ((filters.fields.find(e => e.indexOf('.' + globals.spectrum.dpt) >= 0) || filters.output !== 'json') && 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)) const measurementFilterFields = _.uniq(sortFilterKeys.filter(e => /measurements\./.test(e))
.map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters .map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters
if (sortFilterKeys.find(e => /measurements\./.test(e))) { // add measurement fields 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 if (measurementFilterFields.indexOf(globals.spectrum.spectrum) >= 0) { // filter out dpts
pipeline.push( pipeline.push(
{$project: {'values.device': true, measurement_template: true}}, {$project: {['values.' + globals.spectrum.dpt]: false}},
{$addFields: {'values._id': '$_id'}} {$addFields: {'values._id': '$_id'}}
); );
} }
@ -379,7 +384,6 @@ router.get('/samples', async (req, res, next) => {
projection._id = false; projection._id = false;
} }
queryPtr.push({$project: projection}); queryPtr.push({$project: projection});
console.log(JSON.stringify(query));
// use streaming when including spectrum files // use streaming when including spectrum files
if (!fieldsToAdd.find(e => e.indexOf(globals.spectrum.spectrum + '.' + globals.spectrum.dpt) >= 0)) { if (!fieldsToAdd.find(e => e.indexOf(globals.spectrum.spectrum + '.' + globals.spectrum.dpt) >= 0)) {
collection.aggregate(query).allowDiskUse(true).exec((err, data) => { collection.aggregate(query).allowDiskUse(true).exec((err, data) => {
@ -678,11 +682,10 @@ async function numberGenerate (sample, req, res, next) {
const sampleData = await SampleModel const sampleData = await SampleModel
.aggregate([ .aggregate([
{$match: {number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}}, {$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: { {$addFields: {sortNumber: {$let: {
vars: {tmp: {$concat: ['000000000000000000000000000000', 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'}]} in: {$substrCP: ['$$tmp', {$subtract: [{$strLenCP: '$$tmp'}, 30]}, {$strLenCP: '$$tmp'}]}
}}}}, }}}},
{$sort: {sortNumber: -1}}, {$sort: {sortNumber: -1}},

View File

@ -1,7 +1,11 @@
import should from 'should/as-function'; import should from 'should/as-function';
import _ from 'lodash'; import _ from 'lodash';
import TemplateConditionModel from '../models/condition_template'; 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 TestHelper from "../test/helper";
import mongoose from 'mongoose';
describe('/template', () => { 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 => { it('returns the right condition template', done => {
TestHelper.request(server, done, { TestHelper.request(server, done, {
method: 'put', 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 => { it('creates a changelog', done => {
TestHelper.request(server, done, { TestHelper.request(server, done, {
method: 'put', 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, { TestHelper.request(server, done, {
method: 'put', method: 'put',
url: '/template/condition/200000000000000000000001', url: '/template/condition/200000000000000000000001',
@ -175,7 +197,7 @@ describe('/template', () => {
should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
should(data.first_id.toString()).be.eql('200000000000000000000001'); should(data.first_id.toString()).be.eql('200000000000000000000001');
should(data).have.property('name', 'heat aging'); 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).have.property('parameters').have.lengthOf(2);
should(data.parameters[0]).have.property('name', 'material'); should(data.parameters[0]).have.property('name', 'material');
should(data.parameters[1]).have.property('name', 'weeks'); 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 => { it('supports values ranges', done => {
TestHelper.request(server, done, { TestHelper.request(server, done, {
method: 'put', 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 // 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 // other methods should be covered by condition tests
}); });
}); });

View File

@ -5,6 +5,9 @@ import TemplateValidate from './validate/template';
import ConditionTemplateModel from '../models/condition_template'; import ConditionTemplateModel from '../models/condition_template';
import MeasurementTemplateModel from '../models/measurement_template'; import MeasurementTemplateModel from '../models/measurement_template';
import MaterialTemplateModel from '../models/material_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 res400 from './validate/res400';
import IdValidate from './validate/id'; import IdValidate from './validate/id';
import mongoose from "mongoose"; 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 if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed
template.version = templateData.version + 1; // increase version if (!template.parameters || _.isEqual(templateData.parameters, template.parameters)) { // only name was changed
// save new template, fill with old properties model(req).findByIdAndUpdate(req.params.id, {name: template.name}, {new: true})
await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { .log(req).lean().exec((err, data) => {
if (err) next (err); if (err) next (err);
db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); res.json(TemplateValidate.output(data));
res.json(TemplateValidate.output(data.toObject())); });
}); }
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 { else {
res.json(TemplateValidate.output(templateData)); res.json(TemplateValidate.output(templateData));

View File

@ -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);
}
}

View File

@ -98,6 +98,7 @@ export default class SampleValidate {
'user_id', 'user_id',
'material._id', 'material._id',
'material.numbers', 'material.numbers',
'measurements',
`measurements.${globals.spectrum.spectrum}.${globals.spectrum.dpt}`, `measurements.${globals.spectrum.spectrum}.${globals.spectrum.dpt}`,
]; ];
@ -198,7 +199,6 @@ export default class SampleValidate {
data.filters[i] = decodeURIComponent(data.filters[i]); data.filters[i] = decodeURIComponent(data.filters[i]);
} }
catch (ignore) {} catch (ignore) {}
console.log(data.filters[i]);
data.filters[i] = JSON.parse(data.filters[i]); data.filters[i] = JSON.parse(data.filters[i]);
data.filters[i].values = data.filters[i].values.map(e => { // validate filter values data.filters[i].values = data.filters[i].values.map(e => { // validate filter values
if (e === null) { // null values are always allowed if (e === null) { // null values are always allowed
@ -226,6 +226,12 @@ export default class SampleValidate {
}); });
field = 'value'; field = 'value';
} }
else if (field === 'measurements') {
validator = Joi.object({
value: Joi.object({}).allow(null).disallow({})
});
field = 'value';
}
else if (field === 'notes.comment') { else if (field === 'notes.comment') {
field = 'comment'; field = 'comment';
validator = this.sample.notes validator = this.sample.notes

View File

@ -75,7 +75,7 @@ export default class UserValidate { // validate input for user
}).validate(data); }).validate(data);
} }
else { else {
return{error: 'No parameter specified!', value: {}}; return {error: 'No parameter specified!', value: {}};
} }
} }

View File

@ -893,6 +893,26 @@
"user_id" : {"$oid": "000000000000000000000003"}, "user_id" : {"$oid": "000000000000000000000003"},
"__v" : 0 "__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"
}
] ]
} }
} }