Skip to content

Commit 9462467

Browse files
committed
- DEVSU-2310
- Add migrations to add new legends table and rework pathway analysis legend column - Add new table to store pathway analysis legends - Rework association column between pathway analysis and legends - Add unit tests for new legends endpoints - Update swagger documentation for new legends endpoints - Update mockReportData json with test legend image
1 parent 537c0f8 commit 9462467

11 files changed

Lines changed: 917 additions & 18 deletions

File tree

app/models/index.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,23 @@ summary.probeResults = require('./reports/probeResults')(sequelize, Sq);
133133
summary.therapeuticTargets = require('./reports/genomic/summary/therapeuticTargets')(sequelize, Sq);
134134
summary.microbial = require('./reports/genomic/summary/microbial')(sequelize, Sq);
135135

136+
// Pathway Analysis Legends
137+
const pathwayAnalysisLegends = require('./reports/legend')(sequelize, Sq);
138+
139+
pathwayAnalysisLegends.belongsTo(analysisReports, {
140+
as: 'report', foreignKey: 'reportId', targetKey: 'id', onDelete: 'CASCADE', constraints: true,
141+
});
142+
analysisReports.hasMany(pathwayAnalysisLegends, {
143+
as: 'legends', foreignKey: 'reportId', onDelete: 'CASCADE', constraints: true,
144+
});
145+
146+
summary.pathwayAnalysis.belongsTo(pathwayAnalysisLegends, {
147+
as: 'legend', foreignKey: 'legendId', targetKey: 'id', onDelete: 'SET NULL', constraints: true,
148+
});
149+
pathwayAnalysisLegends.hasMany(summary.pathwayAnalysis, {
150+
as: 'pathwayAnalyses', foreignKey: 'legendId', onDelete: 'SET NULL', constraints: true,
151+
});
152+
136153
analysisReports.belongsTo(user, {
137154
as: 'createdBy', foreignKey: 'createdBy_id', targetKey: 'id', onDelete: 'SET NULL', controlled: true,
138155
});

app/models/reports/genomic/summary/pathwayAnalysis.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ module.exports = (sequelize, Sq) => {
1212
key: 'id',
1313
},
1414
},
15+
legendId: {
16+
name: 'legendId',
17+
field: 'legend_id',
18+
type: Sq.INTEGER,
19+
references: {
20+
model: 'pathway_analysis_legends',
21+
key: 'id',
22+
},
23+
},
1524
pathway: {
1625
type: Sq.TEXT,
1726
allowNull: true,
@@ -20,11 +29,6 @@ module.exports = (sequelize, Sq) => {
2029
schema: {format: 'svg', type: 'string'},
2130
},
2231
},
23-
legend: {
24-
type: Sq.ENUM(['v1', 'v2', 'v3', 'custom']),
25-
allowNull: false,
26-
defaultValue: 'v3',
27-
},
2832
}, {
2933
...DEFAULT_REPORT_OPTIONS,
3034
tableName: 'reports_summary_pathway_analysis',

app/models/reports/legend.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const {DEFAULT_COLUMNS, DEFAULT_REPORT_OPTIONS} = require('../base');
2+
3+
module.exports = (sequelize, Sq) => {
4+
const legend = sequelize.define(
5+
'legend',
6+
{
7+
...DEFAULT_COLUMNS,
8+
reportId: {
9+
name: 'reportId',
10+
field: 'report_id',
11+
type: Sq.INTEGER,
12+
references: {
13+
model: 'reports',
14+
key: 'id',
15+
},
16+
},
17+
format: {
18+
type: Sq.ENUM('PNG', 'JPG'),
19+
defaultValue: 'PNG',
20+
},
21+
filename: {
22+
type: Sq.TEXT,
23+
allowNull: false,
24+
},
25+
version: {
26+
type: Sq.TEXT,
27+
allowNull: false,
28+
},
29+
data: {
30+
type: Sq.TEXT,
31+
allowNull: false,
32+
},
33+
title: {
34+
type: Sq.TEXT,
35+
},
36+
caption: {
37+
type: Sq.TEXT,
38+
},
39+
height: {
40+
type: Sq.INTEGER,
41+
},
42+
width: {
43+
type: Sq.INTEGER,
44+
},
45+
},
46+
{
47+
...DEFAULT_REPORT_OPTIONS,
48+
tableName: 'pathway_analysis_legends',
49+
scopes: {
50+
public: {
51+
attributes: {
52+
exclude: ['id', 'reportId', 'deletedAt', 'updatedBy'],
53+
},
54+
},
55+
versionlist: {
56+
attributes: {
57+
exclude: ['id', 'deletedAt', 'updatedBy', 'data'],
58+
},
59+
},
60+
},
61+
},
62+
);
63+
64+
// set instance methods
65+
legend.prototype.view = function (scope) {
66+
if (scope === 'public') {
67+
const {
68+
id, reportId, deletedAt, updatedBy, ...publicView
69+
} = this.dataValues;
70+
return publicView;
71+
}
72+
if (scope === 'versionlist') {
73+
const {id, deletedAt, updatedBy, exclue, ...versionlistView} = this.dataValues;
74+
return versionlistView;
75+
}
76+
return this;
77+
};
78+
79+
return legend;
80+
};

app/routes/report/images.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,50 @@ const uploadReportImage = async (reportId, key, image, options = {}) => {
4747
}
4848
};
4949

50+
/**
51+
* Resize, reformat and upload a legend image to the pathway_analysis_legends table
52+
*
53+
* @param {Number} reportId - The primary key for the report this legend belongs to (to create FK relationship)
54+
* @param {string} version - The legend version identifier (e.g. 'v1', 'v2', 'v3')
55+
* @param {Buffer|string} image - Buffer containing image data or the absolute path to the image file
56+
* @param {object} options - An object containing additional image upload options
57+
*
58+
* @property {string} options.filename - An optional filename for the image
59+
* @property {string} options.caption - An optional caption for the image
60+
* @property {string} options.title - An optional title for the image
61+
* @property {string} options.category - An optional category for the image
62+
* @property {object} options.transaction - An optional transaction to run the create under
63+
*
64+
* @returns {Promise<object>} - Returns the created legend db entry
65+
* @throws {Promise<Error>} - Something goes wrong with image processing and saving entry
66+
*/
67+
const uploadLegendImage = async (reportId, version, image, options = {}) => {
68+
logger.verbose(`Loading legend (${version}) image`);
69+
70+
const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT};
71+
72+
try {
73+
const imageData = await processImage(image, config.size, config.format);
74+
75+
return db.models.legend.create({
76+
reportId,
77+
format: config.format,
78+
filename: options.filename,
79+
version,
80+
data: imageData,
81+
caption: options.caption,
82+
title: options.title,
83+
width: config.width,
84+
height: config.height,
85+
category: options.category,
86+
}, {transaction: options.transaction});
87+
} catch (error) {
88+
logger.error(`Error processing legend image ${options.filename} ${error}`);
89+
throw new Error(`Error processing legend image ${options.filename} ${error}`);
90+
}
91+
};
92+
5093
module.exports = {
5194
uploadReportImage,
95+
uploadLegendImage,
5296
};

app/routes/report/legend/index.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const HTTP_STATUS = require('http-status-codes');
2+
const express = require('express');
3+
4+
const db = require('../../../models');
5+
const logger = require('../../../log');
6+
const {uploadLegendImage} = require('../images');
7+
8+
const router = express.Router({mergeParams: true});
9+
10+
// Middleware for legend image
11+
router.param('legend', async (req, res, next, imgIdent) => {
12+
let result;
13+
try {
14+
result = await db.models.legend.findOne({
15+
where: {ident: imgIdent, reportId: req.report.id},
16+
});
17+
} catch (error) {
18+
logger.error(`Unable to lookup legend image error: ${error}`);
19+
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to lookup legend image'}});
20+
}
21+
22+
if (!result) {
23+
const message = `Unable to find legend image ${imgIdent} for report ${req.report.ident}`;
24+
logger.error(message);
25+
return res.status(HTTP_STATUS.NOT_FOUND).json({error: {message}});
26+
}
27+
28+
// Add legend data to request
29+
req.legend = result;
30+
return next();
31+
});
32+
33+
// Routes for operating on specific legends
34+
// !!Should not add update routes for legend images (PUT, PATCH, etc.)
35+
// because updates will create duplicate image entries!!
36+
router.route('/:legend([A-z0-9-]{36})')
37+
.get((req, res) => {
38+
return res.json(req.legend.view('public'));
39+
})
40+
.delete(async (req, res) => {
41+
// Whether to hard or soft delete legend
42+
const force = (typeof req.query.force === 'boolean') ? req.query.force : false;
43+
44+
// Delete legend image
45+
try {
46+
await req.legend.destroy({force});
47+
return res.status(HTTP_STATUS.NO_CONTENT).send();
48+
} catch (error) {
49+
logger.error(`Error while deleting legend image ${error}`);
50+
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while deleting legend image'}});
51+
}
52+
});
53+
54+
// Route for adding a legend image
55+
router.route('/')
56+
.post(async (req, res) => {
57+
// Check that image files were uploaded
58+
if (!req.files || Object.keys(req.files).length === 0) {
59+
logger.error('No attached images to upload');
60+
return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}});
61+
}
62+
63+
const versions = [];
64+
65+
for (let [version, value] of Object.entries(req.files)) {
66+
version = version.trim();
67+
68+
// Check if version is a duplicate
69+
if (versions.includes(version) || Array.isArray(value)) {
70+
logger.error(`Duplicate versions are not allowed. Duplicate version: ${version}`);
71+
return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Duplicate versions are not allowed. Duplicate version: ${version}`}});
72+
}
73+
74+
versions.push(version);
75+
}
76+
77+
try {
78+
const results = await Promise.all(Object.entries(req.files).map(async ([version, image]) => {
79+
// Remove trailing space from version
80+
version = version.trim();
81+
82+
try {
83+
// Set options (value or undefined)
84+
const options = {
85+
filename: image.name.trim(),
86+
title: req.body[`${version}_title`],
87+
caption: req.body[`${version}_caption`],
88+
};
89+
90+
// Load image
91+
await uploadLegendImage(req.report.id, version, image.data, options);
92+
// Return that this image was uploaded successfully
93+
return {version, upload: 'successful'};
94+
} catch (error) {
95+
return {version, upload: 'failed', error};
96+
}
97+
}));
98+
return res.status(HTTP_STATUS.MULTI_STATUS).json(results);
99+
} catch (error) {
100+
logger.error(`Error while uploading images ${error}`);
101+
return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error}`}});
102+
}
103+
});
104+
105+
// Route for getting a legend image
106+
router.route('/retrieve/:version')
107+
.get(async (req, res) => {
108+
const versions = (req.params.version.includes(',')) ? req.params.version.split(',') : [req.params.version];
109+
110+
try {
111+
const results = await db.models.imageData.scope('public').findAll({
112+
where: {
113+
reportId: req.report.id,
114+
version: versions,
115+
},
116+
order: [['version', 'ASC']],
117+
});
118+
119+
return res.json(results);
120+
} catch (error) {
121+
logger.error(`Error while getting report images with version: ${req.params.version} ${error}`);
122+
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
123+
error: {message: 'Error while getting report images by version'},
124+
});
125+
}
126+
});
127+
128+
// Route for getting a list of legend versions for the report
129+
router.route('/versionlist')
130+
.get(async (req, res) => {
131+
try {
132+
const results = await db.models.legend.scope('versionlist').findAll({
133+
where: {
134+
reportId: req.report.id,
135+
},
136+
order: [['version', 'ASC']],
137+
});
138+
139+
return res.json(results);
140+
} catch (error) {
141+
logger.error(`Error while getting report legend versionlist: ${error}`);
142+
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
143+
error: {message: 'Error while getting report legend versionlist'},
144+
});
145+
}
146+
});
147+
148+
module.exports = router;

0 commit comments

Comments
 (0)