diff --git a/backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js b/backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js new file mode 100644 index 00000000000..fa085cd5945 --- /dev/null +++ b/backend/src/scripts/utility/modelProcessing/detectZombieProcessing.js @@ -0,0 +1,100 @@ +/** + * Copyright (C) 2024 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * This script is used to check the processing status of models/drawings. + * Processing status should be 'ok' or 'failed'. + * The utility script `resetProcessingFlag` can be used to reset zombie statuses for models. + */ + +const { v5Path } = require('../../../interop'); + +const { logger } = require(`${v5Path}/utils/logger`); +const { getTeamspaceList } = require('../../utils'); + +const { find } = require(`${v5Path}/handler/db`); +const { SETTINGS_COL, processStatuses } = require(`${v5Path}/models/modelSettings.constants`); +const { DRAWINGS_HISTORY_COL } = require(`${v5Path}/models/revisions.constants`); +const { sendSystemEmail } = require(`${v5Path}/services/mailer`); +const { templates: emailTemplates } = require(`${v5Path}/services/mailer/mailer.constants`); +const { UUIDToString } = require(`${v5Path}/utils/helper/uuids`); +const Path = require('path'); + +let TIME_LIMIT = 24 * 60 * 60 * 1000; // hours * 1 hour in ms + +const processTeamspace = async (teamspace) => { + const expiredTimestamp = new Date(new Date() - TIME_LIMIT); + const zombieQuery = { + status: { $exists: true, $not: { $regex: `(${processStatuses.OK})|(${processStatuses.FAILED})` } }, + timestamp: { $lt: expiredTimestamp }, + }; + + logger.logInfo(`\t-${teamspace}`); + + const zombieModels = await find(teamspace, SETTINGS_COL, zombieQuery, { status: 1, timestamp: 1 }); + const zombieDrawings = await find(teamspace, DRAWINGS_HISTORY_COL, zombieQuery, { status: 1, timestamp: 1 }); + + return [ + ...zombieModels.map(({ _id, status, timestamp }) => `${teamspace}, model, ${_id}, ${status}, ${timestamp}`), + ...zombieDrawings.map(({ _id, status, timestamp }) => `${teamspace}, drawing, ${UUIDToString(_id)}, ${status}, ${timestamp}`), + ]; +}; + +const run = async (teamspace, limit, notify) => { + logger.logInfo(`Check processing flag(s) in ${teamspace ?? 'all teamspaces'}`); + + if (limit) { + TIME_LIMIT = limit * 60 * 60 * 1000; + } + + const teamspaces = teamspace ? [teamspace] : await getTeamspaceList(); + const results = (await Promise.all(teamspaces.map((ts) => processTeamspace(ts)))).flat(); + + if (notify && results.length > 0) { + logger.logInfo(`Zombie processing statuses found: ${results.length}`); + const data = { + script: Path.basename(__filename, Path.extname(__filename)), + title: 'Zombie processing statuses found', + message: `${results.length} zombie processing statuses found`, + logExcerpt: JSON.stringify(results), + }; + await sendSystemEmail(emailTemplates.ZOMBIE_PROCESSING_STATUSES.name, data); + } +}; + +const genYargs = /* istanbul ignore next */(yargs) => { + const commandName = Path.basename(__filename, Path.extname(__filename)); + const argsSpec = (subYargs) => subYargs.option('teamspace', { + describe: 'Target a specific teamspace (if unspecified, all teamspaces will be targetted)', + type: 'string', + }).option('limit', { + describe: 'Time limit (hours, default: 24) where models/drawings may still be processing', + type: 'number', + }).option('notify', { + describe: 'Send e-mail notification if results are found (default: false)', + type: 'boolean', + }); + return yargs.command(commandName, + 'Checks the processing status of models/drawings.', + argsSpec, + (argv) => run(argv.teamspace, argv.limit, argv.notify)); +}; + +module.exports = { + run, + genYargs, +}; diff --git a/backend/src/v5/services/mailer/mailer.constants.js b/backend/src/v5/services/mailer/mailer.constants.js index 910b3ed6d74..aae01705397 100644 --- a/backend/src/v5/services/mailer/mailer.constants.js +++ b/backend/src/v5/services/mailer/mailer.constants.js @@ -21,6 +21,7 @@ const forgotPasswordSso = require('./templates/forgotPasswordSSO'); const modelImportError = require('./templates/modelImportError'); const { toConstantCase } = require('../../utils/helper/strings'); const verifyUser = require('./templates/verifyUser'); +const zombieProcessingStatuses = require('./templates/zombieProcessingStatuses'); const MailerConstants = {}; @@ -30,6 +31,7 @@ const templates = { forgotPasswordSso, errorNotification, modelImportError, + zombieProcessingStatuses, }; MailerConstants.templates = {}; diff --git a/backend/src/v5/services/mailer/templates/html/zombieProcessingStatuses.html b/backend/src/v5/services/mailer/templates/html/zombieProcessingStatuses.html new file mode 100644 index 00000000000..b7f74cb677e --- /dev/null +++ b/backend/src/v5/services/mailer/templates/html/zombieProcessingStatuses.html @@ -0,0 +1,6 @@ +

<%= message %> on <%= domain %>

+

Log:

+

+ <%-logExcerpt-%> +

+ diff --git a/backend/src/v5/services/mailer/templates/zombieProcessingStatuses.js b/backend/src/v5/services/mailer/templates/zombieProcessingStatuses.js new file mode 100644 index 00000000000..00f413a49e0 --- /dev/null +++ b/backend/src/v5/services/mailer/templates/zombieProcessingStatuses.js @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2024 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const Yup = require('yup'); +const config = require('../../../utils/config'); +const { generateTemplateFn } = require('./common'); + +const TEMPLATE_PATH = `${__dirname}/html/zombieProcessingStatuses.html`; + +const dataSchema = Yup.object({ + script: Yup.string().default(''), + title: Yup.string().default('Zombie Processing Statuses'), + domain: Yup.string().default(() => config.getBaseURL()), + logExcerpt: Yup.string().default('No logs found.').transform( + (val) => val.replaceAll('<', '<').replaceAll('>', '>').replace(/(\r\n|\n|\r)/gm, '
')), + +}).required(true); + +const ZombieProcessingStatusesTemplate = {}; +ZombieProcessingStatusesTemplate.subject = (data) => { + const { domain, title, script } = dataSchema.cast(data); + return `[${domain}][${script}] ${title}`; +}; + +ZombieProcessingStatusesTemplate.html = generateTemplateFn(dataSchema, TEMPLATE_PATH); + +module.exports = ZombieProcessingStatusesTemplate; diff --git a/backend/src/v5/services/modelProcessing.js b/backend/src/v5/services/modelProcessing.js index 8ef211a004c..807cccbaf6f 100644 --- a/backend/src/v5/services/modelProcessing.js +++ b/backend/src/v5/services/modelProcessing.js @@ -79,7 +79,7 @@ const queueDrawingUpload = async (teamspace, project, model, revId, data, fileBu database: teamspace, project: model, revId, - file, + file: `${SHARED_SPACE_TAG}/${revId}/${revId}${data.format}`, }; await mkdir(pathToRevFolder); diff --git a/backend/src/v5/utils/helper/units.js b/backend/src/v5/utils/helper/units.js index 277303f626c..a9e284205aa 100644 --- a/backend/src/v5/utils/helper/units.js +++ b/backend/src/v5/utils/helper/units.js @@ -30,12 +30,13 @@ const UNITS_CONVERSION_FACTORS_TO_METRES = { UnitsHelper.convertArrayUnits = (array, fromUnit, toUnit) => { const fromFactor = UNITS_CONVERSION_FACTORS_TO_METRES[fromUnit]; const toFactor = UNITS_CONVERSION_FACTORS_TO_METRES[toUnit]; + const scale = toFactor / fromFactor; if (!array.every(isNumber) || !fromFactor || !toFactor) { - return null; + return array; } - return array.map((n) => (n / fromFactor) * toFactor); + return array.map((n) => n * scale); }; module.exports = UnitsHelper; diff --git a/backend/tests/v5/helper/services.js b/backend/tests/v5/helper/services.js index a45e5831394..8879f9a80bd 100644 --- a/backend/tests/v5/helper/services.js +++ b/backend/tests/v5/helper/services.js @@ -458,16 +458,17 @@ ServiceHelper.generateRandomModel = ({ modelType = modelTypes.CONTAINER, viewers }; }; -ServiceHelper.generateRevisionEntry = (isVoid = false, hasFile = true, modelType) => { +ServiceHelper.generateRevisionEntry = (isVoid = false, hasFile = true, modelType, timestamp, status) => { const _id = ServiceHelper.generateUUIDString(); const entry = deleteIfUndefined({ _id, tag: modelType === modelTypes.DRAWING ? undefined : ServiceHelper.generateRandomString(), + status, statusCode: modelType === modelTypes.DRAWING ? statusCodes[0].code : undefined, revCode: modelType === modelTypes.DRAWING ? ServiceHelper.generateRandomString(10) : undefined, format: modelType === modelTypes.DRAWING ? '.pdf' : undefined, author: ServiceHelper.generateRandomString(), - timestamp: ServiceHelper.generateRandomDate(), + timestamp: timestamp || ServiceHelper.generateRandomDate(), desc: ServiceHelper.generateRandomString(), void: !!isVoid, }); diff --git a/backend/tests/v5/scripts/modelProcessing/detectZombieProcessing.test.js b/backend/tests/v5/scripts/modelProcessing/detectZombieProcessing.test.js new file mode 100644 index 00000000000..3aae102e81d --- /dev/null +++ b/backend/tests/v5/scripts/modelProcessing/detectZombieProcessing.test.js @@ -0,0 +1,131 @@ +/** + * Copyright (C) 2024 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { + determineTestGroup, + db: { reset: resetDB, createModel, createRevision }, + generateRandomString, + generateRandomModel, + generateRevisionEntry, +} = require('../../helper/services'); +const { times } = require('lodash'); +const { utilScripts, src } = require('../../helper/path'); + +const { modelTypes, processStatuses } = require(`${src}/models/modelSettings.constants`); +const { deleteIfUndefined } = require(`${src}/utils/helper/objects`); +const { disconnect } = require(`${src}/handler/db`); +const { templates: emailTemplates } = require(`${src}/services/mailer/mailer.constants`); + +jest.mock('../../../../src/v5/services/mailer'); +const Mailer = require(`${src}/services/mailer`); + +const DetectZombieProcessing = require(`${utilScripts}/modelProcessing/detectZombieProcessing`); +const Path = require('path'); + +const modelStates = Object.values(processStatuses); + +const recentDate = new Date((new Date()) - 36 * 60 * 60 * 1000); + +const setupData = () => { + const modelProms = times(2, async () => { + const teamspace = generateRandomString(); + const models = await Promise.all(times(modelStates.length, async (n) => { + const { _id, name, properties } = generateRandomModel({ + properties: deleteIfUndefined({ status: modelStates[n], timestamp: recentDate }), + }); + await createModel(teamspace, _id, name, properties); + return { model: _id, status: modelStates[n] }; + })); + const drawings = await Promise.all(times(modelStates.length, async (n) => { + const project = generateRandomString(); + const revision = generateRevisionEntry(false, false, modelTypes.DRAWING, recentDate, modelStates[n]); + await createRevision(teamspace, project, revision._id, revision, modelTypes.DRAWING); + return { drawing: revision._id, status: modelStates[n] }; + })); + + return { teamspace, models, drawings }; + }); + return Promise.all(modelProms); +}; + +const checkMail = (data, filteredTeamspace) => { + const expectedLogExcerpt = data.map(({ teamspace, models, drawings }) => { + if (!filteredTeamspace || teamspace === filteredTeamspace) { + const expectedModels = models.map(({ model, status }) => ( + (status !== processStatuses.OK && status !== processStatuses.FAILED) + ? `${teamspace}, model, ${model}, ${status}, ${recentDate}` : '')); + const expectedDrawings = drawings.map(({ drawing, status }) => ( + (status !== processStatuses.OK && status !== processStatuses.FAILED) + ? `${teamspace}, drawing, ${drawing}, ${status}, ${recentDate}` : '')); + return [...expectedModels, ...expectedDrawings]; + } + return undefined; + }).flat().filter(Boolean); + const expectedData = { + script: Path.basename(__filename, Path.extname(__filename)).replace(/\.test/, ''), + title: 'Zombie processing statuses found', + message: `${expectedLogExcerpt.length} zombie processing statuses found`, + }; + expect(Mailer.sendSystemEmail).toHaveBeenCalledWith( + emailTemplates.ZOMBIE_PROCESSING_STATUSES.name, + expect.objectContaining(expectedData), + ); + const actualLogExcerpt = JSON.parse(Mailer.sendSystemEmail.mock.calls[0][1].logExcerpt); + expect(actualLogExcerpt).toEqual(expect.arrayContaining(expectedLogExcerpt)); +}; + +const runTest = () => { + describe('Detect zombie processing', () => { + let data; + beforeEach(async () => { + await resetDB(); + data = await setupData(); + }); + + test('Should do nothing if notify is not set', async () => { + await DetectZombieProcessing.run(); + expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(0); + }); + + test('Should send system mail if notify is set', async () => { + await DetectZombieProcessing.run(undefined, undefined, true); + expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(1); + checkMail(data); + }); + + test('Should send system mail if the predefined teamspace exists', async () => { + await DetectZombieProcessing.run(data[0].teamspace, undefined, true); + expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(1); + checkMail(data, data[0].teamspace); + }); + + test('Should do nothing if teamspace is not found', async () => { + await DetectZombieProcessing.run(generateRandomString(), undefined, true); + expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(0); + }); + + test('Should do nothing if time limit is extended', async () => { + await DetectZombieProcessing.run(undefined, 48 * 60 * 60 * 1000, true); + expect(Mailer.sendSystemEmail).toHaveBeenCalledTimes(0); + }); + }); +}; + +describe(determineTestGroup(__filename), () => { + runTest(); + afterAll(disconnect); +}); diff --git a/backend/tests/v5/unit/services/mailer/templates/zombieProcessingStatuses.test.js b/backend/tests/v5/unit/services/mailer/templates/zombieProcessingStatuses.test.js new file mode 100644 index 00000000000..9abc3524b8e --- /dev/null +++ b/backend/tests/v5/unit/services/mailer/templates/zombieProcessingStatuses.test.js @@ -0,0 +1,51 @@ +/** + * Copyright (C) 2024 3D Repo Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const { src } = require('../../../../helper/path'); +const { determineTestGroup, generateRandomString } = require('../../../../helper/services'); +const isHtml = require('is-html-content'); + +const ZombieProcessingStatuses = require(`${src}/services/mailer/templates/zombieProcessingStatuses`); + +const testHtml = () => { + describe('get template html', () => { + test('should get zombieProcessingStatuses template html', async () => { + const res = await ZombieProcessingStatuses.html({ + message: generateRandomString(), + domain: generateRandomString(), + logExcerpt: generateRandomString(), + }); + expect(isHtml(res)).toEqual(true); + }); + }); +}; + +const testSubject = () => { + describe.each([ + ['data object is empty', {}], + ['data object is not empty', { domain: generateRandomString(), title: generateRandomString(), script: generateRandomString() }], + ])('get subject', (desc, data) => { + test(`should succeed if ${desc}`, () => { + expect(ZombieProcessingStatuses.subject(data).length).not.toEqual(0); + }); + }); +}; + +describe(determineTestGroup(__filename), () => { + testHtml(); + testSubject(); +}); diff --git a/backend/tests/v5/unit/utils/helper/units.test.js b/backend/tests/v5/unit/utils/helper/units.test.js index a05c09457ef..a050c6cdd95 100644 --- a/backend/tests/v5/unit/utils/helper/units.test.js +++ b/backend/tests/v5/unit/utils/helper/units.test.js @@ -20,36 +20,51 @@ const { generateRandomString } = require('../../../helper/services'); const UnitsHelper = require(`${src}/utils/helper/units`); +const UNITS_CONVERSION_FACTORS_TO_METRES = { + m: 1, + dm: 10, + cm: 100, + mm: 1000, + ft: 3.28084, +}; + +const getScaleFactor = (fromUnit, toUnit) => { + const fromFactor = UNITS_CONVERSION_FACTORS_TO_METRES[fromUnit]; + const toFactor = UNITS_CONVERSION_FACTORS_TO_METRES[toUnit]; + return toFactor / fromFactor; +}; + const testConvertArrayUnits = () => { describe.each([ - ['invalid fromUnit', [1, 5], generateRandomString(), 'm'], - ['invalid toUnit', [1, 5], 'm', generateRandomString()], - ['array with non numbers', [generateRandomString(), 5], 'm', 'mm'], - ['m to dm', [1, 5], 'm', 'dm', [10, 50]], - ['m to cm', [1, 5], 'm', 'cm', [100, 500]], - ['m to mm', [1, 5], 'm', 'mm', [1000, 5000]], - ['m to ft', [1, 5], 'm', 'ft', [3.281, 16.404]], - ['dm to m', [1, 5], 'dm', 'm', [0.1, 0.5]], - ['dm to cm', [1, 5], 'dm', 'cm', [10, 50]], - ['dm to mm', [1, 5], 'dm', 'mm', [100, 500]], - ['dm to ft', [1, 5], 'dm', 'ft', [0.328, 1.640]], - ['mm to dm', [1, 5], 'mm', 'dm', [0.01, 0.05]], - ['mm to cm', [1, 5], 'mm', 'cm', [0.1, 0.5]], - ['mm to m', [1, 5], 'mm', 'm', [0.001, 0.005]], - ['mm to ft', [1, 5], 'mm', 'ft', [0.003, 0.016]], - ['ft to dm', [1, 5], 'ft', 'dm', [3.048, 15.24]], - ['ft to cm', [1, 5], 'ft', 'cm', [30.48, 152.4]], - ['ft to m', [1, 5], 'ft', 'm', [0.305, 1.524]], - ['ft to mm', [1, 5], 'ft', 'mm', [304.8, 1524]], - ])('Convert array units', (description, array, fromUnit, toUnit, result = null) => { - test(`with ${description} should return ${result}`, () => { - let res = UnitsHelper.convertArrayUnits(array, fromUnit, toUnit); + ['invalid fromUnit', [1, 5], generateRandomString(), 'm', true], + ['invalid toUnit', [1, 5], 'm', generateRandomString(), true], + ['array with non numbers', [generateRandomString(), 5], 'm', 'mm', true], + ['m to dm', [1, 5], 'm', 'dm'], + ['m to cm', [1, 5], 'm', 'cm'], + ['m to mm', [1, 5], 'm', 'mm'], + ['m to ft', [1, 5], 'm', 'ft'], + ['dm to m', [1, 5], 'dm', 'm'], + ['dm to cm', [1, 5], 'dm', 'cm'], + ['dm to mm', [1, 5], 'dm', 'mm'], + ['dm to ft', [1, 5], 'dm', 'ft'], + ['mm to dm', [1, 5], 'mm', 'dm'], + ['mm to cm', [1, 5], 'mm', 'cm'], + ['mm to m', [1, 5], 'mm', 'm'], + ['mm to ft', [1, 5], 'mm', 'ft'], + ['ft to dm', [1, 5], 'ft', 'dm'], + ['ft to cm', [1, 5], 'ft', 'cm'], + ['ft to m', [1, 5], 'ft', 'm'], + ['ft to mm', [1, 5], 'ft', 'mm'], + ])('Convert array units', (description, array, fromUnit, toUnit, invalidInput) => { + test(`with ${description} should return ${invalidInput ? 'the same array' : 'the converted array'}`, () => { + const res = UnitsHelper.convertArrayUnits(array, fromUnit, toUnit); - if (res) { - res = res.map((r) => Math.round(r * 1000) / 1000); + if (invalidInput) { + expect(res).toEqual(array); + } else { + const scaleFactor = getScaleFactor(fromUnit, toUnit); + expect(res).toEqual(array.map((n) => n * scaleFactor)); } - - expect(res).toEqual(result); }); }); };