Archived
2

implementation of measurement fields

This commit is contained in:
VLE2FE 2020-06-30 14:16:37 +02:00
parent 52eb828bea
commit 8cf1c14d88
7 changed files with 145 additions and 26 deletions

View File

@ -42,12 +42,14 @@
schema: schema:
type: boolean type: boolean
example: false example: false
- name: fields - name: fields[]
description: the fields to include in the output as array, defaults to ['_id','number','type','batch','material_id','color','condition','note_id','user_id','added'] description: the fields to include in the output as array, defaults to ['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']
in: query in: query
schema: schema:
type: array
items:
type: string type: string
example: '&fields[]=number&fields[]=batch' example: ['number', 'batch']
responses: responses:
200: 200:
description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format)

View File

@ -32,10 +32,10 @@ async function main() {
await allSamples(); await allSamples();
await saveSamples(); await saveSamples();
} }
else if (0) { // DPT else if (1) { // DPT
await allDpts(); await allDpts();
} }
else if (1) { // KF/VZ else if (0) { // KF/VZ
await importCsv(); await importCsv();
await allKfVz(); await allKfVz();
} }

View File

@ -1,7 +1,34 @@
import {parseAsync} from 'json2csv'; import {parseAsync} from 'json2csv';
export default function csv(input: object, fields: string[], f: (err, data) => void) { export default function csv(input: any[], f: (err, data) => void) {
parseAsync(input) parseAsync(input.map(e => flatten(e)))
.then(csv => f(null, csv)) .then(csv => f(null, csv))
.catch(err => f(err, null)); .catch(err => f(err, null));
} }
function flatten (data) { // flatten object: {a: {b: true}} -> {a.b: true}
const result = {};
function recurse (cur, prop) {
if (Object(cur) !== cur || Object.keys(cur).length === 0) {
result[prop] = cur;
}
else if (Array.isArray(cur)) {
let l = 0;
for(let i = 0, l = cur.length; i < l; i++)
recurse(cur[i], prop + "[" + i + "]");
if (l == 0)
result[prop] = [];
}
else {
let isEmpty = true;
for (let p in cur) {
isEmpty = false;
recurse(cur[p], prop ? prop+"."+p : p);
}
if (isEmpty && prop)
result[prop] = {};
}
}
recurse(data, '');
return result;
}

View File

@ -244,6 +244,42 @@ describe('/sample', () => {
done(); done();
}); });
}); });
it('adds the specified measurements', done => {
TestHelper.request(server, done, {
method: 'get',
url: '/samples?status=all&fields[]=number&fields[]=measurements.kf',
auth: {basic: 'janedoe'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body.find(e => e.number === '1')).have.property('kf', {});
should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.5, 'standard deviation': null});
done();
});
});
it('multiplies the sample information for each spectrum', done => {
TestHelper.request(server, done, {
method: 'get',
url: '/samples?status=all&fields[]=number&fields[]=measurements.spectrum',
auth: {basic: 'janedoe'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body).have.lengthOf(2);
should(res.body[0]).have.property('spectrum', [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]);
should(res.body[1]).have.property('spectrum', [[3996.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]);
done();
});
});
it('rejects unknown measurement names', done => {
TestHelper.request(server, done, {
method: 'get',
url: '/samples?status=all&fields[]=number&fields[]=measurements.xx',
auth: {basic: 'janedoe'},
httpStatus: 400,
res: {status: 'Invalid body format', details: 'Measurement key not found'}
});
});
it('returns a correct csv file if specified', done => { it('returns a correct csv file if specified', done => {
TestHelper.request(server, done, { TestHelper.request(server, done, {
method: 'get', method: 'get',
@ -253,9 +289,9 @@ describe('/sample', () => {
httpStatus: 200 httpStatus: 200
}).end((err, res) => { }).end((err, res) => {
if (err) return done(err); if (err) return done(err);
should(res.text).be.eql('"_id","number","type","color","batch","condition","material_id","note_id","user_id","added"\r\n' + should(res.text).be.eql('"_id","number","type","color","batch","condition.material","condition.weeks","condition.condition_template","material_id","note_id","user_id","added"\r\n' +
'"400000000000000000000001","1","granulate","black","","{""material"":""copper"",""weeks"":3,""condition_template"":""200000000000000000000001""}","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' + '"400000000000000000000001","1","granulate","black","","copper",3,"200000000000000000000001","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' +
'"400000000000000000000002","21","granulate","natural","1560237365","{""material"":""copper"",""weeks"":3,""condition_template"":""200000000000000000000001""}","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"'); '"400000000000000000000002","21","granulate","natural","1560237365","copper",3,"200000000000000000000001","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"');
done(); done();
}); });
}); });
@ -268,6 +304,15 @@ describe('/sample', () => {
res: [{number: '1', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, color: 'black', material: {name: 'Schulamid 66 GF 25 H', mineral: 0}}] res: [{number: '1', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, color: 'black', material: {name: 'Schulamid 66 GF 25 H', mineral: 0}}]
}); });
}); });
it('rejects a from-id not in the database', done => {
TestHelper.request(server, done, {
method: 'get',
url: '/samples?from-id=5ea0450ed851c30a90e70894&sort=color-asc',
auth: {basic: 'admin'},
httpStatus: 400,
res: {status: 'Invalid body format', details: 'from-id not found'}
});
});
it('rejects an invalid fields parameter', done => { it('rejects an invalid fields parameter', done => {
TestHelper.request(server, done, { TestHelper.request(server, done, {
method: 'get', method: 'get',
@ -283,7 +328,7 @@ describe('/sample', () => {
url: '/samples?status=all&page-size=1&fields[]=xx', url: '/samples?status=all&page-size=1&fields[]=xx',
auth: {basic: 'janedoe'}, auth: {basic: 'janedoe'},
httpStatus: 400, httpStatus: 400,
res: {status: 'Invalid body format', details: '"fields[0]" must be one of [_id, color, number, type, batch, added, material.name, material.supplier, material.group, material.mineral, material.glass_fiber, material.carbon_fiber, material.number, condition, material_id, note_id, user_id, material._id, material.numbers]'} res: {status: 'Invalid body format', details: '"fields[0]" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\..+)$/m'}
}); });
}); });
it('rejects a negative page size', done => { it('rejects a negative page size', done => {
@ -329,7 +374,7 @@ describe('/sample', () => {
httpStatus: 401 httpStatus: 401
}); });
}); });
}); // TODO: measurement fields });
describe('GET /samples/{state}', () => { describe('GET /samples/{state}', () => {
it('returns all new samples', done => { it('returns all new samples', done => {

View File

@ -6,6 +6,7 @@ import NoteFieldValidate from './validate/note_field';
import res400 from './validate/res400'; import res400 from './validate/res400';
import SampleModel from '../models/sample' import SampleModel from '../models/sample'
import MeasurementModel from '../models/measurement'; import MeasurementModel from '../models/measurement';
import MeasurementTemplateModel from '../models/measurement_template';
import MaterialModel from '../models/material'; import MaterialModel from '../models/material';
import NoteModel from '../models/note'; import NoteModel from '../models/note';
import NoteFieldModel from '../models/note_field'; import NoteFieldModel from '../models/note_field';
@ -63,6 +64,9 @@ router.get('/samples', async (req, res, next) => {
if (filters['from-id']) { // from-id specified if (filters['from-id']) { // from-id specified
const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);});
if (fromSample instanceof Error) return; if (fromSample instanceof Error) return;
if (!fromSample) {
return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'});
}
if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc
query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]});
@ -77,10 +81,6 @@ router.get('/samples', async (req, res, next) => {
query.push({$sort: {[filters.sort[0]]: filters.sort[1], '_id': filters.sort[1]}}); // set _id as secondary sort query.push({$sort: {[filters.sort[0]]: filters.sort[1], '_id': filters.sort[1]}}); // set _id as secondary sort
} }
// if (filters.sort[0].indexOf('material.') >= 0) { // unpopulate materials again
// query.push({$unset: 'material'});
// }
if (filters['to-page']) { if (filters['to-page']) {
query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more
} }
@ -88,13 +88,38 @@ router.get('/samples', async (req, res, next) => {
if (filters['page-size']) { if (filters['page-size']) {
query.push({$limit: filters['page-size']}); query.push({$limit: filters['page-size']});
} }
console.log(filters.fields);
const projection = filters.fields.reduce((s, e) => {s[e] = true; return s; }, {}); let measurementFields = [];
if (filters.fields.find(e => /measurements\./.test(e))) { // joining measurements is required
query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}});
measurementFields = filters.fields.filter(e => /measurements\./.test(e)).map(e => e.replace('measurements.', ''));
const measurementTemplates = await MeasurementTemplateModel.find({$or: measurementFields.map(e => {return {name: e}})}).lean().exec().catch(err => {next(err);});
if (measurementTemplates instanceof Error) return;
if (measurementTemplates.length < measurementFields.length) {
return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'});
}
measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later
query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values
vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}},
in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']}
}}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', {}]}}});
});
console.log(measurementFields);
if (measurementFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well
query.push(
{$set: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}},
{$set: {spectrum: '$spectrum.values.dpt'}},
{$unwind: '$spectrum'}
);
}
query.push({$unset: 'measurements'});
}
const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {});
if (filters.fields.indexOf('added') >= 0) { // add added date if (filters.fields.indexOf('added') >= 0) { // add added date
projection.added = {$toDate: '$_id'}; projection.added = {$toDate: '$_id'};
} }
if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly
console.log('disable id');
projection._id = false; projection._id = false;
} }
query.push({$project: projection}); query.push({$project: projection});
@ -104,15 +129,16 @@ router.get('/samples', async (req, res, next) => {
if (filters['to-page'] < 0) { if (filters['to-page'] < 0) {
data.reverse(); data.reverse();
} }
if (filters.csv) { // output as csv // TODO: csv example in OAS console.log(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))));
csv(_.compact(data.map(e => SampleValidate.output(e, 'refs'))), ['_id', 'number'], (err, data) => { if (filters.csv) { // output as csv
csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => {
if (err) return next(err); if (err) return next(err);
res.set('Content-Type', 'text/csv'); res.set('Content-Type', 'text/csv');
res.send(data); res.send(data);
}); });
} }
else { else {
res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs')))); // validate all and filter null values from validation errors res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); // validate all and filter null values from validation errors
} }
}) })
}); });

View File

@ -71,10 +71,12 @@ export default class SampleValidate {
...SampleValidate.sortKeys, ...SampleValidate.sortKeys,
'condition', 'condition',
'material_id', 'material_id',
'material',
'note_id', 'note_id',
'user_id', 'user_id',
'material._id', 'material._id',
'material.numbers' 'material.numbers',
'measurements.*'
]; ];
static input (data, param) { // validate input, set param to 'new' to make all attributes required static input (data, param) { // validate input, set param to 'new' to make all attributes required
@ -114,7 +116,7 @@ export default class SampleValidate {
} }
} }
static output (data, param = 'refs+added') { // validate output and strip unwanted properties, returns null if not valid static output (data, param = 'refs+added', additionalParams = []) { // validate output and strip unwanted properties, returns null if not valid
if (param === 'refs+added') { if (param === 'refs+added') {
param = 'refs'; param = 'refs';
data.added = data._id.getTimestamp(); data.added = data._id.getTimestamp();
@ -130,7 +132,7 @@ export default class SampleValidate {
batch: this.sample.batch, batch: this.sample.batch,
condition: this.sample.condition, condition: this.sample.condition,
material_id: IdValidate.get(), material_id: IdValidate.get(),
material: MaterialValidate.outputV(), material: MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')}),
note_id: IdValidate.get().allow(null), note_id: IdValidate.get().allow(null),
user_id: IdValidate.get(), user_id: IdValidate.get(),
added: this.sample.added added: this.sample.added
@ -153,6 +155,9 @@ export default class SampleValidate {
else { else {
return null; return null;
} }
additionalParams.forEach(param => {
joiObject[param] = Joi.any();
});
const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true});
return error !== undefined? null : value; return error !== undefined? null : value;
} }
@ -165,7 +170,7 @@ export default class SampleValidate {
'page-size': Joi.number().integer().min(1), 'page-size': Joi.number().integer().min(1),
sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.') + ')-(asc|desc)$', 'm')).default('_id-asc'), sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.') + ')-(asc|desc)$', 'm')).default('_id-asc'),
csv: Joi.boolean().default(false), csv: Joi.boolean().default(false),
fields: Joi.array().items(Joi.string().valid(...this.fieldKeys)).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']) fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added'])
}).with('to-page', 'page-size').validate(data); }).with('to-page', 'page-size').validate(data);
} }
} }

View File

@ -417,6 +417,20 @@
"status": 0, "status": 0,
"measurement_template": {"$oid":"300000000000000000000002"}, "measurement_template": {"$oid":"300000000000000000000002"},
"__v": 0 "__v": 0
},
{
"_id": {"$oid":"800000000000000000000007"},
"sample_id": {"$oid":"400000000000000000000001"},
"values": {
"dpt": [
[3996.12558,98.00555],
[3995.08519,98.03253],
[3993.04480,98.02657]
]
},
"status": 10,
"measurement_template": {"$oid":"300000000000000000000001"},
"__v": 0
} }
], ],
"condition_templates": [ "condition_templates": [