Added printer and filament profile schemas, routes, and associated logic for managing profiles. Enhanced authentication for slicer uploads and integrated new routes for handling slicer-related operations. Updated database utilities and services to support new features, improving overall functionality and user experience.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit is contained in:
parent
e5981ce419
commit
cb16385b33
133
src/__tests__/keycloak.apppassword.test.js
Normal file
133
src/__tests__/keycloak.apppassword.test.js
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
const userModelMock = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const appPasswordQueryMock = {
|
||||||
|
select: jest.fn(),
|
||||||
|
lean: jest.fn(),
|
||||||
|
};
|
||||||
|
const appPasswordModelMock = {
|
||||||
|
find: jest.fn(() => appPasswordQueryMock),
|
||||||
|
};
|
||||||
|
const bcryptCompareMock = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../config.js', () => ({
|
||||||
|
default: { server: { logLevel: 'info' } },
|
||||||
|
getEnvironment: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: 'info',
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('node-cache', () => ({
|
||||||
|
default: class {
|
||||||
|
get() {}
|
||||||
|
set() {}
|
||||||
|
on() {}
|
||||||
|
flushAll() {}
|
||||||
|
getStats() {}
|
||||||
|
del() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('bcrypt', () => ({
|
||||||
|
default: { compare: bcryptCompareMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../database/schemas/management/user.schema.js', () => ({
|
||||||
|
userModel: userModelMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../database/schemas/management/apppassword.schema.js', () => ({
|
||||||
|
appPasswordModel: appPasswordModelMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../database/database.js', () => ({
|
||||||
|
getObject: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../database/schemas/management/host.schema.js', () => ({
|
||||||
|
hostModel: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../services/misc/auth.js', () => ({
|
||||||
|
getSession: jest.fn(),
|
||||||
|
lookupUserByToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { isAppAuthenticated } = await import('../keycloak.js');
|
||||||
|
|
||||||
|
describe('app-password authentication', () => {
|
||||||
|
let req;
|
||||||
|
let res;
|
||||||
|
let next;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
appPasswordQueryMock.select.mockReturnValue(appPasswordQueryMock);
|
||||||
|
req = {
|
||||||
|
headers: {
|
||||||
|
authorization: `Basic ${Buffer.from('slicer-user:app-secret').toString('base64')}`,
|
||||||
|
},
|
||||||
|
params: { username: 'slicer-user' },
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn(),
|
||||||
|
};
|
||||||
|
next = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid username and app password', async () => {
|
||||||
|
const user = { _id: 'user-1', username: 'slicer-user' };
|
||||||
|
userModelMock.findOne.mockResolvedValue(user);
|
||||||
|
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
||||||
|
bcryptCompareMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await isAppAuthenticated(req, res, next);
|
||||||
|
|
||||||
|
expect(req.user).toBe(user);
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the app password from the password route parameter', async () => {
|
||||||
|
const user = { _id: 'user-1', username: 'slicer-user' };
|
||||||
|
req.headers = {};
|
||||||
|
req.params.password = 'app-secret';
|
||||||
|
userModelMock.findOne.mockResolvedValue(user);
|
||||||
|
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
||||||
|
bcryptCompareMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await isAppAuthenticated(req, res, next);
|
||||||
|
|
||||||
|
expect(bcryptCompareMock).toHaveBeenCalledWith('app-secret', 'stored-hash');
|
||||||
|
expect(req.user).toBe(user);
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid app password', async () => {
|
||||||
|
userModelMock.findOne.mockResolvedValue({ _id: 'user-1', username: 'slicer-user' });
|
||||||
|
appPasswordQueryMock.lean.mockResolvedValue([{ secret: 'stored-hash' }]);
|
||||||
|
bcryptCompareMock.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await isAppAuthenticated(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Not Authenticated',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -556,6 +556,15 @@ export const listObjectsByProperties = async ({
|
|||||||
// Build aggregation pipeline
|
// Build aggregation pipeline
|
||||||
const pipeline = [];
|
const pipeline = [];
|
||||||
|
|
||||||
|
logger.debug('Master filter:', masterFilter);
|
||||||
|
|
||||||
|
// Match before populate so reference fields (e.g. filament) are still ObjectIds
|
||||||
|
if (Object.keys(masterFilter).length > 0) {
|
||||||
|
const convertedFilter = convertObjectIdStringsInFilter(masterFilter);
|
||||||
|
logger.debug('Converted filter:', convertedFilter);
|
||||||
|
pipeline.push({ $match: convertedFilter });
|
||||||
|
}
|
||||||
|
|
||||||
// Handle populate (array or single value)
|
// Handle populate (array or single value)
|
||||||
if (populate) {
|
if (populate) {
|
||||||
const populates = Array.isArray(populate) ? populate : [populate];
|
const populates = Array.isArray(populate) ? populate : [populate];
|
||||||
@ -599,14 +608,6 @@ export const listObjectsByProperties = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Master filter:', masterFilter);
|
|
||||||
|
|
||||||
if (Object.keys(masterFilter).length > 0) {
|
|
||||||
const convertedFilter = convertObjectIdStringsInFilter(masterFilter);
|
|
||||||
logger.debug('Converted filter:', convertedFilter);
|
|
||||||
pipeline.push({ $match: convertedFilter });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (propertiesPresent) {
|
if (propertiesPresent) {
|
||||||
// Build the $group _id object for all properties
|
// Build the $group _id object for all properties
|
||||||
const groupId = {};
|
const groupId = {};
|
||||||
|
|||||||
@ -55,6 +55,25 @@ class RedisServer {
|
|||||||
await this.client.del(key);
|
await this.client.del(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAndDeleteKey(key) {
|
||||||
|
await this.connect();
|
||||||
|
const value = await this.client.getDel(key);
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async eval(script, keys = [], args = []) {
|
||||||
|
await this.connect();
|
||||||
|
return this.client.eval(script, {
|
||||||
|
keys,
|
||||||
|
arguments: args.map((arg) => String(arg)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getKeysByPattern(pattern) {
|
async getKeysByPattern(pattern) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const keys = [];
|
const keys = [];
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { jobModel } from './production/job.schema.js';
|
import { jobModel } from './production/job.schema.js';
|
||||||
import { subJobModel } from './production/subjob.schema.js';
|
import { subJobModel } from './production/subjob.schema.js';
|
||||||
import { printerModel } from './production/printer.schema.js';
|
import { printerModel } from './production/printer.schema.js';
|
||||||
|
import { printerProfileModel } from './production/printerprofile.schema.js';
|
||||||
|
import { filamentProfileModel } from './production/filamentprofile.schema.js';
|
||||||
import { filamentModel } from './management/filament.schema.js';
|
import { filamentModel } from './management/filament.schema.js';
|
||||||
import { filamentSkuModel } from './management/filamentsku.schema.js';
|
import { filamentSkuModel } from './management/filamentsku.schema.js';
|
||||||
import { gcodeFileModel } from './production/gcodefile.schema.js';
|
import { gcodeFileModel } from './production/gcodefile.schema.js';
|
||||||
@ -55,6 +57,20 @@ export const models = {
|
|||||||
referenceField: '_reference',
|
referenceField: '_reference',
|
||||||
label: 'Printer',
|
label: 'Printer',
|
||||||
},
|
},
|
||||||
|
PPF: {
|
||||||
|
model: printerProfileModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'printerProfile',
|
||||||
|
referenceField: '_reference',
|
||||||
|
label: 'Printer Profile',
|
||||||
|
},
|
||||||
|
FPF: {
|
||||||
|
model: filamentProfileModel,
|
||||||
|
idField: '_id',
|
||||||
|
type: 'filamentProfile',
|
||||||
|
referenceField: '_reference',
|
||||||
|
label: 'Filament Profile',
|
||||||
|
},
|
||||||
FIL: {
|
FIL: {
|
||||||
model: filamentModel,
|
model: filamentModel,
|
||||||
idField: '_id',
|
idField: '_id',
|
||||||
|
|||||||
165
src/database/schemas/production/filamentprofile.schema.js
Normal file
165
src/database/schemas/production/filamentprofile.schema.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
const filamentProfileSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
filamentType: { type: String, required: true },
|
||||||
|
filament: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
refPath: 'filamentType',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
filamentIsSupport: { type: Boolean },
|
||||||
|
filamentSoluble: { type: Boolean },
|
||||||
|
filamentPrintable: { type: Number },
|
||||||
|
filamentAdhesivenessCategory: { type: Number },
|
||||||
|
temperatureVitrification: { type: Number },
|
||||||
|
idleTemperature: { type: Number },
|
||||||
|
pelletFlowCoefficient: { type: Number },
|
||||||
|
requiredNozzleHrc: { type: Number },
|
||||||
|
filamentFlowRatio: { type: Number },
|
||||||
|
enablePressureAdvance: { type: Boolean },
|
||||||
|
pressureAdvance: { type: Number },
|
||||||
|
adaptivePressureAdvance: { type: Boolean },
|
||||||
|
adaptivePressureAdvanceBridges: { type: Boolean },
|
||||||
|
adaptivePressureAdvanceOverhangs: { type: Boolean },
|
||||||
|
adaptivePressureAdvanceModel: { type: String },
|
||||||
|
activateChamberTempControl: { type: Boolean },
|
||||||
|
chamberTemperature: { type: Number },
|
||||||
|
chamberMinimalTemperature: { type: Number },
|
||||||
|
nozzleTemperatureInitialLayer: { type: Number },
|
||||||
|
nozzleTemperature: { type: Number },
|
||||||
|
nozzleTemperatureRangeLow: { type: Number },
|
||||||
|
nozzleTemperatureRangeHigh: { type: Number },
|
||||||
|
hotPlateTempInitialLayer: { type: Number },
|
||||||
|
hotPlateTemp: { type: Number },
|
||||||
|
coolPlateTempInitialLayer: { type: Number },
|
||||||
|
coolPlateTemp: { type: Number },
|
||||||
|
engPlateTempInitialLayer: { type: Number },
|
||||||
|
engPlateTemp: { type: Number },
|
||||||
|
texturedPlateTempInitialLayer: { type: Number },
|
||||||
|
texturedPlateTemp: { type: Number },
|
||||||
|
texturedCoolPlateTempInitialLayer: { type: Number },
|
||||||
|
texturedCoolPlateTemp: { type: Number },
|
||||||
|
supertackPlateTempInitialLayer: { type: Number },
|
||||||
|
supertackPlateTemp: { type: Number },
|
||||||
|
filamentAdaptiveVolumetricSpeed: { type: Boolean },
|
||||||
|
filamentMaxVolumetricSpeed: { type: Number },
|
||||||
|
volumetricSpeedCoefficients: { type: String },
|
||||||
|
closeFanTheFirstXLayers: { type: Number },
|
||||||
|
fullFanSpeedLayer: { type: Number },
|
||||||
|
fanMinSpeed: { type: Number },
|
||||||
|
fanMaxSpeed: { type: Number },
|
||||||
|
reduceFanStopStartFreq: { type: Boolean },
|
||||||
|
slowDownForLayerCooling: { type: Boolean },
|
||||||
|
dontSlowDownOuterWall: { type: Boolean },
|
||||||
|
slowDownMinSpeed: { type: Number },
|
||||||
|
slowDownLayerTime: { type: Number },
|
||||||
|
fanCoolingLayerTime: { type: Number },
|
||||||
|
enableOverhangBridgeFan: { type: Boolean },
|
||||||
|
overhangFanThreshold: { type: String },
|
||||||
|
overhangFanSpeed: { type: Number },
|
||||||
|
internalBridgeFanSpeed: { type: Number },
|
||||||
|
supportMaterialInterfaceFanSpeed: { type: Number },
|
||||||
|
ironingFanSpeed: { type: Number },
|
||||||
|
initialLayerFanSpeed: { type: Number },
|
||||||
|
firstXLayerFanSpeed: { type: Number },
|
||||||
|
additionalCoolingFanSpeed: { type: Number },
|
||||||
|
additionalFanFullSpeedLayer: { type: Number },
|
||||||
|
closeAdditionalFanFirstXLayers: { type: Boolean },
|
||||||
|
activateAirFiltration: { type: Boolean },
|
||||||
|
activateAirFiltrationDuringPrint: { type: Boolean },
|
||||||
|
activateAirFiltrationOnCompletion: { type: Boolean },
|
||||||
|
duringPrintExhaustFanSpeed: { type: Number },
|
||||||
|
completePrintExhaustFanSpeed: { type: Number },
|
||||||
|
filamentRetractionLength: { type: Number },
|
||||||
|
filamentRetractionSpeed: { type: Number },
|
||||||
|
filamentDeretractionSpeed: { type: Number },
|
||||||
|
filamentRetractionMinimumTravel: { type: Number },
|
||||||
|
filamentRetractWhenChangingLayer: { type: Boolean },
|
||||||
|
filamentRetractBeforeWipe: { type: Number },
|
||||||
|
filamentRetractAfterWipe: { type: Number },
|
||||||
|
filamentRetractRestartExtra: { type: Number },
|
||||||
|
filamentRetractLiftAbove: { type: Number },
|
||||||
|
filamentRetractLiftBelow: { type: Number },
|
||||||
|
filamentRetractLiftEnforce: { type: String },
|
||||||
|
filamentWipe: { type: Boolean },
|
||||||
|
filamentWipeDistance: { type: Number },
|
||||||
|
filamentZHop: { type: Number },
|
||||||
|
filamentZHopTypes: { type: String },
|
||||||
|
filamentLongRetractionsWhenCut: { type: Boolean },
|
||||||
|
filamentRetractionDistancesWhenCut: { type: Number },
|
||||||
|
longRetractionsWhenEc: { type: Boolean },
|
||||||
|
retractionDistancesWhenEc: { type: Number },
|
||||||
|
filamentLoadingSpeed: { type: Number },
|
||||||
|
filamentLoadingSpeedStart: { type: Number },
|
||||||
|
filamentUnloadingSpeed: { type: Number },
|
||||||
|
filamentUnloadingSpeedStart: { type: Number },
|
||||||
|
filamentChangeLength: { type: Number },
|
||||||
|
filamentChangeLengthNc: { type: Number },
|
||||||
|
filamentToolchangeDelay: { type: Number },
|
||||||
|
filamentExtruderCompatibility: { type: Number },
|
||||||
|
filamentExtruderVariant: { type: String },
|
||||||
|
filamentMultitoolRamming: { type: Boolean },
|
||||||
|
filamentMultitoolRammingFlow: { type: Number },
|
||||||
|
filamentMultitoolRammingVolume: { type: Number },
|
||||||
|
filamentRammingParameters: { type: String },
|
||||||
|
filamentRammingTravelTime: { type: Number },
|
||||||
|
filamentRammingTravelTimeNc: { type: Number },
|
||||||
|
filamentRammingVolumetricSpeed: { type: Number },
|
||||||
|
filamentRammingVolumetricSpeedNc: { type: Number },
|
||||||
|
filamentMinimalPurgeOnWipeTower: { type: Number },
|
||||||
|
filamentTowerInterfacePreExtrusionDist: { type: Number },
|
||||||
|
filamentTowerInterfacePreExtrusionLength: { type: Number },
|
||||||
|
filamentTowerInterfacePrintTemp: { type: Number },
|
||||||
|
filamentTowerInterfacePurgeVolume: { type: Number },
|
||||||
|
filamentTowerIroningArea: { type: Number },
|
||||||
|
filamentCoolingBeforeTower: { type: Number },
|
||||||
|
filamentCoolingInitialSpeed: { type: Number },
|
||||||
|
filamentCoolingFinalSpeed: { type: Number },
|
||||||
|
filamentCoolingMoves: { type: Number },
|
||||||
|
filamentFlushTemp: { type: Number },
|
||||||
|
filamentFlushTempFast: { type: Number },
|
||||||
|
filamentFlushVolumetricSpeed: { type: Number },
|
||||||
|
filamentPreCoolingTemperature: { type: Number },
|
||||||
|
filamentPreCoolingTemperatureNc: { type: Number },
|
||||||
|
filamentPreheatTemperatureDelta: { type: Number },
|
||||||
|
filamentPrimeVolumeNc: { type: Number },
|
||||||
|
filamentRetractLengthNc: { type: Number },
|
||||||
|
filamentStampingDistance: { type: Number },
|
||||||
|
filamentStampingLoadingSpeed: { type: Number },
|
||||||
|
filamentStartGcode: { type: String },
|
||||||
|
filamentEndGcode: { type: String },
|
||||||
|
filamentChangeExtrusionRoleGcode: { type: String },
|
||||||
|
filamentShrink: { type: String },
|
||||||
|
filamentShrinkageCompensationZ: { type: String },
|
||||||
|
filamentDevAmsDryingTemperature: { type: Number },
|
||||||
|
filamentDevAmsDryingTime: { type: Number },
|
||||||
|
filamentDevAmsDryingHeatDistortionTemperature: { type: Number },
|
||||||
|
filamentDevAmsDryingAmsLimitations: { type: String },
|
||||||
|
filamentDevChamberDryingBedTemperature: { type: Number },
|
||||||
|
filamentDevChamberDryingTime: { type: Number },
|
||||||
|
filamentDevDryingCoolingTemperature: { type: Number },
|
||||||
|
filamentDevDryingSofteningTemperature: { type: Number },
|
||||||
|
compatiblePrinters: [
|
||||||
|
{ type: mongoose.Schema.Types.ObjectId, ref: 'printerProfile' },
|
||||||
|
],
|
||||||
|
compatiblePrintersCondition: { type: String },
|
||||||
|
compatiblePrints: [{ type: String }],
|
||||||
|
compatiblePrintsCondition: { type: String },
|
||||||
|
filamentNotes: { type: String },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
filamentProfileSchema.index({ name: 'text', filamentNotes: 'text' });
|
||||||
|
|
||||||
|
filamentProfileSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
filamentProfileSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const filamentProfileModel = mongoose.model('filamentProfile', filamentProfileSchema);
|
||||||
@ -25,6 +25,18 @@ const alertSchema = new Schema(
|
|||||||
{ timestamps: true, _id: false }
|
{ timestamps: true, _id: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pendingSlicerUploadSchema = new Schema(
|
||||||
|
{
|
||||||
|
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
||||||
|
gcodeFile: { type: Schema.Types.ObjectId, ref: 'gcodeFile', default: null },
|
||||||
|
job: { type: Schema.Types.ObjectId, ref: 'job', default: null },
|
||||||
|
shouldPrint: { type: Boolean, required: true, default: false },
|
||||||
|
new: { type: Boolean, required: true, default: true },
|
||||||
|
properties: { type: Object, required: true, default: () => ({}) },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
// Define the main printer schema
|
// Define the main printer schema
|
||||||
const printerSchema = new Schema(
|
const printerSchema = new Schema(
|
||||||
{
|
{
|
||||||
@ -53,6 +65,7 @@ const printerSchema = new Schema(
|
|||||||
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
|
vendor: { type: Schema.Types.ObjectId, ref: 'vendor', default: null },
|
||||||
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
|
host: { type: Schema.Types.ObjectId, ref: 'host', default: null },
|
||||||
alerts: [alertSchema],
|
alerts: [alertSchema],
|
||||||
|
pendingSlicerUploads: { type: [pendingSlicerUploadSchema], default: [] },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
111
src/database/schemas/production/printerprofile.schema.js
Normal file
111
src/database/schemas/production/printerprofile.schema.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { generateId } from '../../utils.js';
|
||||||
|
|
||||||
|
const minMaxNumberSchema = {
|
||||||
|
min: { type: Number },
|
||||||
|
max: { type: Number }
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbnailSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
width: { type: Number },
|
||||||
|
height: { type: Number }
|
||||||
|
},
|
||||||
|
{ _id: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const bedExcludeAreaSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
x: { type: Number },
|
||||||
|
y: { type: Number }
|
||||||
|
},
|
||||||
|
{ _id: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const extruderSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
defaultNozzleVolumeType: { type: String },
|
||||||
|
nozzleDiameter: { type: Number },
|
||||||
|
nozzleVolume: { type: Number },
|
||||||
|
nozzleType: { type: String },
|
||||||
|
nozzleFlushDataset: { type: String },
|
||||||
|
minLayerHeight: { type: Number },
|
||||||
|
maxLayerHeight: { type: Number },
|
||||||
|
extruderColour: { type: String },
|
||||||
|
extruderType: { type: String },
|
||||||
|
extruderVariantList: { type: String },
|
||||||
|
extruderPrintableHeight: { type: Number },
|
||||||
|
printerExtruderId: { type: String },
|
||||||
|
printerExtruderVariant: { type: String },
|
||||||
|
positionOffsetX: { type: Number },
|
||||||
|
positionOffsetY: { type: Number },
|
||||||
|
retractionLength: { type: Number },
|
||||||
|
retractionExtraLengthOnRestart: { type: Number },
|
||||||
|
retractionSpeed: { type: Number },
|
||||||
|
deretractionSpeed: { type: Number },
|
||||||
|
retractionTravelDistanceThreshold: { type: Number },
|
||||||
|
retractOnLayerChange: { type: Boolean },
|
||||||
|
wipeWhileRetracting: { type: Boolean },
|
||||||
|
wipeDistance: { type: Number },
|
||||||
|
retractAmountBeforeWipe: { type: Number },
|
||||||
|
retractAmountAfterWipe: { type: Number },
|
||||||
|
zHopOnSurfaces: { type: String },
|
||||||
|
zHopType: { type: String },
|
||||||
|
zHopHeight: { type: Number },
|
||||||
|
zHopTravelingAngle: { type: Number },
|
||||||
|
zHopOnlyLiftZAbove: { type: Number },
|
||||||
|
zHopOnlyLiftZBelow: { type: Number },
|
||||||
|
materialSwitchRetractionLength: { type: Number },
|
||||||
|
materialSwitchExtraLengthOnRestart: { type: Number },
|
||||||
|
longRetractionWhenCut: { type: Boolean },
|
||||||
|
retractionDistancesWhenCut: { type: Number },
|
||||||
|
},
|
||||||
|
{ _id: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const printerProfileSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
_reference: { type: String, default: () => generateId()() },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
printer: { type: mongoose.Schema.Types.ObjectId, ref: 'printer', default: null },
|
||||||
|
extruders: [extruderSchema],
|
||||||
|
printBedWidth: { type: Number },
|
||||||
|
printBedHeight: { type: Number },
|
||||||
|
originX: { type: Number },
|
||||||
|
originY: { type: Number },
|
||||||
|
bedExcludeArea: [bedExcludeAreaSchema],
|
||||||
|
printableHeight: { type: Number },
|
||||||
|
supportMultiBedTypes: { type: Boolean },
|
||||||
|
bestObjectPositionX: { type: Number },
|
||||||
|
bestObjectPositionY: { type: Number },
|
||||||
|
zOffset: { type: Number },
|
||||||
|
preferredOrientation: { type: Number },
|
||||||
|
machineMaxAccelerationRetracting: minMaxNumberSchema,
|
||||||
|
machineMaxSpeedE: minMaxNumberSchema,
|
||||||
|
machineMaxSpeedX: minMaxNumberSchema,
|
||||||
|
machineMaxSpeedY: minMaxNumberSchema,
|
||||||
|
machinePauseGcode: { type: String },
|
||||||
|
machineStartGcode: { type: String },
|
||||||
|
machineEndGcode: { type: String },
|
||||||
|
layerChangeGcode: { type: String },
|
||||||
|
beforeLayerChangeGcode: { type: String },
|
||||||
|
printerNotes: { type: String },
|
||||||
|
scanFirstLayer: { type: Boolean },
|
||||||
|
machineLoadFilamentTime: { type: Number },
|
||||||
|
machineUnloadFilamentTime: { type: Number },
|
||||||
|
thumbnails: [thumbnailSchema],
|
||||||
|
auxiliaryFan: { type: Boolean },
|
||||||
|
machineMaxJunctionDeviation: minMaxNumberSchema,
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
printerProfileSchema.index({ name: 'text', printerNotes: 'text' });
|
||||||
|
|
||||||
|
printerProfileSchema.virtual('id').get(function () {
|
||||||
|
return this._id;
|
||||||
|
});
|
||||||
|
|
||||||
|
printerProfileSchema.set('toJSON', { virtuals: true });
|
||||||
|
|
||||||
|
export const printerProfileModel = mongoose.model('printerProfile', printerProfileSchema);
|
||||||
@ -20,6 +20,32 @@ export function toObjectIdIfValid(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract an ObjectId from a populated reference object, if applicable. */
|
||||||
|
function extractReferenceId(value) {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
value instanceof mongoose.Types.ObjectId ||
|
||||||
|
value instanceof Date ||
|
||||||
|
value._id == null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
const looksLikeReference = keys.every((k) => k === '_id' || k === '__v');
|
||||||
|
if (!looksLikeReference) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = value._id;
|
||||||
|
if (id instanceof mongoose.Types.ObjectId) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return toObjectIdIfValid(id?.toString?.() ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */
|
/** Recursively convert ObjectId strings to ObjectId in a filter object for MongoDB $match. */
|
||||||
export function convertObjectIdStringsInFilter(filter) {
|
export function convertObjectIdStringsInFilter(filter) {
|
||||||
if (!filter || typeof filter !== 'object') return filter;
|
if (!filter || typeof filter !== 'object') return filter;
|
||||||
@ -28,22 +54,31 @@ export function convertObjectIdStringsInFilter(filter) {
|
|||||||
for (const [key, value] of Object.entries(filter)) {
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
if (key.startsWith('$')) {
|
if (key.startsWith('$')) {
|
||||||
if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
|
if ((key === '$in' || key === '$nin') && Array.isArray(value)) {
|
||||||
result[key] = value.map((v) => (isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v));
|
result[key] = value.map((v) => {
|
||||||
|
const refId = extractReferenceId(v);
|
||||||
|
if (refId !== null) return refId;
|
||||||
|
return isObjectIdString(v) ? new mongoose.Types.ObjectId(v) : v;
|
||||||
|
});
|
||||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
result[key] = convertObjectIdStringsInFilter(value);
|
result[key] = convertObjectIdStringsInFilter(value);
|
||||||
} else {
|
} else {
|
||||||
result[key] = toObjectIdIfValid(value);
|
result[key] = toObjectIdIfValid(value);
|
||||||
}
|
}
|
||||||
} else if (
|
|
||||||
value &&
|
|
||||||
typeof value === 'object' &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
!(value instanceof mongoose.Types.ObjectId) &&
|
|
||||||
!(value instanceof Date)
|
|
||||||
) {
|
|
||||||
result[key] = convertObjectIdStringsInFilter(value);
|
|
||||||
} else {
|
} else {
|
||||||
result[key] = toObjectIdIfValid(value);
|
const refId = extractReferenceId(value);
|
||||||
|
if (refId !== null) {
|
||||||
|
result[key] = refId;
|
||||||
|
} else if (
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
!(value instanceof mongoose.Types.ObjectId) &&
|
||||||
|
!(value instanceof Date)
|
||||||
|
) {
|
||||||
|
result[key] = convertObjectIdStringsInFilter(value);
|
||||||
|
} else {
|
||||||
|
result[key] = toObjectIdIfValid(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
appPasswordRoutes,
|
appPasswordRoutes,
|
||||||
fileRoutes,
|
fileRoutes,
|
||||||
printerRoutes,
|
printerRoutes,
|
||||||
|
printerProfileRoutes,
|
||||||
|
filamentProfileRoutes,
|
||||||
jobRoutes,
|
jobRoutes,
|
||||||
subJobRoutes,
|
subJobRoutes,
|
||||||
gcodeFileRoutes,
|
gcodeFileRoutes,
|
||||||
@ -61,6 +63,7 @@ import {
|
|||||||
appLaunchRoutes,
|
appLaunchRoutes,
|
||||||
appUpdateRoutes,
|
appUpdateRoutes,
|
||||||
serverRoutes,
|
serverRoutes,
|
||||||
|
slicerRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@ -139,6 +142,8 @@ app.use('/apppasswords', appPasswordRoutes);
|
|||||||
app.use('/files', fileRoutes);
|
app.use('/files', fileRoutes);
|
||||||
app.use('/spotlight', spotlightRoutes);
|
app.use('/spotlight', spotlightRoutes);
|
||||||
app.use('/printers', printerRoutes);
|
app.use('/printers', printerRoutes);
|
||||||
|
app.use('/printerprofiles', printerProfileRoutes);
|
||||||
|
app.use('/filamentprofiles', filamentProfileRoutes);
|
||||||
app.use('/hosts', hostRoutes);
|
app.use('/hosts', hostRoutes);
|
||||||
app.use('/jobs', jobRoutes);
|
app.use('/jobs', jobRoutes);
|
||||||
app.use('/subjobs', subJobRoutes);
|
app.use('/subjobs', subJobRoutes);
|
||||||
@ -189,6 +194,7 @@ app.use('/csv', csvRoutes);
|
|||||||
app.use('/applaunch', appLaunchRoutes);
|
app.use('/applaunch', appLaunchRoutes);
|
||||||
app.use('/appupdate', appUpdateRoutes);
|
app.use('/appupdate', appUpdateRoutes);
|
||||||
app.use('/server', serverRoutes);
|
app.use('/server', serverRoutes);
|
||||||
|
app.use('/slicer', slicerRoutes);
|
||||||
|
|
||||||
// Start the application
|
// Start the application
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
|||||||
@ -95,7 +95,7 @@ const isAuthenticated = async (req, res, next) => {
|
|||||||
const authenticateWithAppPassword = async (username, secret) => {
|
const authenticateWithAppPassword = async (username, secret) => {
|
||||||
if (!username || !secret) return null;
|
if (!username || !secret) return null;
|
||||||
|
|
||||||
const user = await userModel.findOne({ username });
|
const user = await userModel.findOne({ username }).lean();
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const appPasswords = await appPasswordModel
|
const appPasswords = await appPasswordModel
|
||||||
@ -115,6 +115,17 @@ const authenticateWithAppPassword = async (username, secret) => {
|
|||||||
|
|
||||||
const isAppAuthenticated = async (req, res, next) => {
|
const isAppAuthenticated = async (req, res, next) => {
|
||||||
const authHeader = req.headers.authorization || req.headers.Authorization;
|
const authHeader = req.headers.authorization || req.headers.Authorization;
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
const userParam = req.params.username;
|
||||||
|
const passwordParam = req.params.password;
|
||||||
|
|
||||||
|
logger.debug('App authentication request', {
|
||||||
|
hasBasicAuth: authHeader?.startsWith('Basic ') === true,
|
||||||
|
hasApiKey: Boolean(apiKey),
|
||||||
|
hasPasswordParam: Boolean(passwordParam),
|
||||||
|
userParam,
|
||||||
|
});
|
||||||
|
|
||||||
// Supports HTTP Basic Auth (username + app password secret)
|
// Supports HTTP Basic Auth (username + app password secret)
|
||||||
if (authHeader?.startsWith('Basic ')) {
|
if (authHeader?.startsWith('Basic ')) {
|
||||||
try {
|
try {
|
||||||
@ -134,6 +145,16 @@ const isAppAuthenticated = async (req, res, next) => {
|
|||||||
logger.error('Basic auth error:', error.message);
|
logger.error('Basic auth error:', error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const appPassword = apiKey || passwordParam;
|
||||||
|
if (appPassword && userParam) {
|
||||||
|
logger.debug('App password and username present', { userParam });
|
||||||
|
const user = await authenticateWithAppPassword(userParam, appPassword);
|
||||||
|
if (user) {
|
||||||
|
req.user = user;
|
||||||
|
req.session = { user };
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import appPasswordRoutes from './management/apppasswords.js';
|
|||||||
import fileRoutes from './management/files.js';
|
import fileRoutes from './management/files.js';
|
||||||
import authRoutes from './misc/auth.js';
|
import authRoutes from './misc/auth.js';
|
||||||
import printerRoutes from './production/printers.js';
|
import printerRoutes from './production/printers.js';
|
||||||
|
import printerProfileRoutes from './production/printerprofiles.js';
|
||||||
|
import filamentProfileRoutes from './production/filamentprofiles.js';
|
||||||
import hostRoutes from './management/hosts.js';
|
import hostRoutes from './management/hosts.js';
|
||||||
import jobRoutes from './production/jobs.js';
|
import jobRoutes from './production/jobs.js';
|
||||||
import subJobRoutes from './production/subjobs.js';
|
import subJobRoutes from './production/subjobs.js';
|
||||||
@ -54,6 +56,7 @@ import csvRoutes from './misc/csv.js';
|
|||||||
import appLaunchRoutes from './misc/applaunch.js';
|
import appLaunchRoutes from './misc/applaunch.js';
|
||||||
import appUpdateRoutes from './misc/appupdate.js';
|
import appUpdateRoutes from './misc/appupdate.js';
|
||||||
import serverRoutes from './misc/server.js';
|
import serverRoutes from './misc/server.js';
|
||||||
|
import slicerRoutes from './misc/slicer.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
userRoutes,
|
userRoutes,
|
||||||
@ -61,6 +64,8 @@ export {
|
|||||||
fileRoutes,
|
fileRoutes,
|
||||||
authRoutes,
|
authRoutes,
|
||||||
printerRoutes,
|
printerRoutes,
|
||||||
|
printerProfileRoutes,
|
||||||
|
filamentProfileRoutes,
|
||||||
hostRoutes,
|
hostRoutes,
|
||||||
jobRoutes,
|
jobRoutes,
|
||||||
subJobRoutes,
|
subJobRoutes,
|
||||||
@ -112,4 +117,5 @@ export {
|
|||||||
appLaunchRoutes,
|
appLaunchRoutes,
|
||||||
appUpdateRoutes,
|
appUpdateRoutes,
|
||||||
serverRoutes,
|
serverRoutes,
|
||||||
|
slicerRoutes,
|
||||||
};
|
};
|
||||||
|
|||||||
23
src/routes/misc/slicer.js
Normal file
23
src/routes/misc/slicer.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAppAuthenticated } from '../../keycloak.js';
|
||||||
|
import {
|
||||||
|
slicerUploadRouteHandler,
|
||||||
|
slicerUIRouteHandler,
|
||||||
|
slicerVersionRouteHandler,
|
||||||
|
} from '../../services/misc/slicer.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/:printerId/:username/:password', isAppAuthenticated, slicerUIRouteHandler);
|
||||||
|
router.get(
|
||||||
|
'/:printerId/:username/:password/api/version',
|
||||||
|
isAppAuthenticated,
|
||||||
|
slicerVersionRouteHandler
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:printerId/:username/:password/api/files/local',
|
||||||
|
isAppAuthenticated,
|
||||||
|
slicerUploadRouteHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
48
src/routes/production/filamentprofiles.js
Normal file
48
src/routes/production/filamentprofiles.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { convertPropertiesString, getFilter } from '../../utils.js';
|
||||||
|
import {
|
||||||
|
deleteFilamentProfileRouteHandler,
|
||||||
|
editFilamentProfileRouteHandler,
|
||||||
|
getFilamentProfileRouteHandler,
|
||||||
|
listFilamentProfilesByPropertiesRouteHandler,
|
||||||
|
listFilamentProfilesRouteHandler,
|
||||||
|
newFilamentProfileRouteHandler,
|
||||||
|
searchFilamentProfilesRouteHandler,
|
||||||
|
} from '../../services/production/filamentprofiles.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const filter = getFilter(req.query, ['_id', 'name']);
|
||||||
|
listFilamentProfilesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
const properties = convertPropertiesString(req.query.properties);
|
||||||
|
const filter = getFilter(req.query, ['name'], false);
|
||||||
|
listFilamentProfilesByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/search', isAuthenticated, (req, res) => {
|
||||||
|
searchFilamentProfilesRouteHandler(req, res, req.query.search);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newFilamentProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getFilamentProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, (req, res) => {
|
||||||
|
editFilamentProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, (req, res) => {
|
||||||
|
deleteFilamentProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
48
src/routes/production/printerprofiles.js
Normal file
48
src/routes/production/printerprofiles.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { isAuthenticated } from '../../keycloak.js';
|
||||||
|
import { convertPropertiesString, getFilter } from '../../utils.js';
|
||||||
|
import {
|
||||||
|
deletePrinterProfileRouteHandler,
|
||||||
|
editPrinterProfileRouteHandler,
|
||||||
|
getPrinterProfileRouteHandler,
|
||||||
|
listPrinterProfilesByPropertiesRouteHandler,
|
||||||
|
listPrinterProfilesRouteHandler,
|
||||||
|
newPrinterProfileRouteHandler,
|
||||||
|
searchPrinterProfilesRouteHandler,
|
||||||
|
} from '../../services/production/printerprofiles.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', isAuthenticated, (req, res) => {
|
||||||
|
const { page, limit, property, search, sort, order } = req.query;
|
||||||
|
const filter = getFilter(req.query, ['_id', 'name']);
|
||||||
|
listPrinterProfilesRouteHandler(req, res, page, limit, property, filter, search, sort, order);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/properties', isAuthenticated, (req, res) => {
|
||||||
|
const properties = convertPropertiesString(req.query.properties);
|
||||||
|
const filter = getFilter(req.query, ['name'], false);
|
||||||
|
listPrinterProfilesByPropertiesRouteHandler(req, res, properties, filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/search', isAuthenticated, (req, res) => {
|
||||||
|
searchPrinterProfilesRouteHandler(req, res, req.query.search);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', isAuthenticated, (req, res) => {
|
||||||
|
newPrinterProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', isAuthenticated, (req, res) => {
|
||||||
|
getPrinterProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', isAuthenticated, (req, res) => {
|
||||||
|
editPrinterProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', isAuthenticated, (req, res) => {
|
||||||
|
deletePrinterProfileRouteHandler(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -52,16 +52,11 @@ jest.unstable_mockModule('log4js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { listFilesRouteHandler, getFileRouteHandler, editFileRouteHandler, flushFileRouteHandler } =
|
||||||
listFilesRouteHandler,
|
await import('../files.js');
|
||||||
getFileRouteHandler,
|
|
||||||
editFileRouteHandler,
|
|
||||||
flushFileRouteHandler,
|
|
||||||
} = await import('../files.js');
|
|
||||||
|
|
||||||
const { listObjects, getObject, editObject, flushFile } = await import(
|
const { listObjects, getObject, editObject, flushFile } =
|
||||||
'../../../database/database.js'
|
await import('../../../database/database.js');
|
||||||
);
|
|
||||||
const { fileModel } = await import('../../../database/schemas/management/file.schema.js');
|
const { fileModel } = await import('../../../database/schemas/management/file.schema.js');
|
||||||
|
|
||||||
describe('File Service Route Handlers', () => {
|
describe('File Service Route Handlers', () => {
|
||||||
@ -121,4 +116,3 @@ describe('File Service Route Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
listObjectsByProperties,
|
listObjectsByProperties,
|
||||||
getModelStats,
|
getModelStats,
|
||||||
getModelHistory,
|
getModelHistory,
|
||||||
searchObjects
|
searchObjects,
|
||||||
} from '../../database/database.js';
|
} from '../../database/database.js';
|
||||||
const logger = log4js.getLogger('Filament SKUs');
|
const logger = log4js.getLogger('Filament SKUs');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
@ -60,7 +60,7 @@ export const listFilamentSkusByPropertiesRouteHandler = async (
|
|||||||
model: filamentSkuModel,
|
model: filamentSkuModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['costTaxRate'],
|
populate: ['costTaxRate', 'filament'],
|
||||||
masterFilter,
|
masterFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
flushFile,
|
flushFile,
|
||||||
getModelStats,
|
getModelStats,
|
||||||
getModelHistory,
|
getModelHistory,
|
||||||
searchObjects
|
searchObjects,
|
||||||
} from '../../database/database.js';
|
} from '../../database/database.js';
|
||||||
import {
|
import {
|
||||||
uploadFile,
|
uploadFile,
|
||||||
@ -28,6 +28,17 @@ import { getFileMeta } from '../../utils.js';
|
|||||||
const logger = log4js.getLogger('Files');
|
const logger = log4js.getLogger('Files');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const getDownloadContentType = (file) => {
|
||||||
|
// G-code is text, but it must be transferred byte-for-byte. Some slicers
|
||||||
|
// report text/plain, causing proxies to compress an otherwise fixed-length
|
||||||
|
// response and browsers to reject it with a content-length network error.
|
||||||
|
if (file.extension?.toLowerCase() === '.gcode') {
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.type || 'application/octet-stream';
|
||||||
|
};
|
||||||
|
|
||||||
// Set storage engine to memory for Ceph upload
|
// Set storage engine to memory for Ceph upload
|
||||||
const fileStorage = multer.memoryStorage();
|
const fileStorage = multer.memoryStorage();
|
||||||
|
|
||||||
@ -288,9 +299,31 @@ function checkFileType(file, cb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getFileContentRouteHandler = async (req, res) => {
|
export const getFileContentRouteHandler = async (req, res) => {
|
||||||
try {
|
const id = req.params.id;
|
||||||
const id = req.params.id;
|
let bytesSent = 0;
|
||||||
|
let sourceEnded = false;
|
||||||
|
|
||||||
|
logger.info(`File download requested: ${id}`);
|
||||||
|
|
||||||
|
req.once('aborted', () => {
|
||||||
|
logger.warn(`File download request aborted by client: ${id} (${bytesSent} bytes sent)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.once('finish', () => {
|
||||||
|
logger.info(
|
||||||
|
`File download response finished: ${id} (status ${res.statusCode}, ${bytesSent} bytes sent)`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.once('close', () => {
|
||||||
|
if (!res.writableFinished) {
|
||||||
|
logger.warn(
|
||||||
|
`File download response closed early: ${id} (${bytesSent} bytes sent, source ended: ${sourceEnded})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const file = await getObject({
|
const file = await getObject({
|
||||||
model: fileModel,
|
model: fileModel,
|
||||||
id,
|
id,
|
||||||
@ -302,27 +335,64 @@ export const getFileContentRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.trace(`Returning file contents with ID: ${id}:`);
|
logger.trace(`Returning file contents with ID: ${id}:`);
|
||||||
|
logger.debug(`File record loaded for download: ${id}`, {
|
||||||
|
name: file.name,
|
||||||
|
extension: file.extension,
|
||||||
|
recordedSize: file.size,
|
||||||
|
type: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
// Check if file is stored in Ceph
|
// Check if file is stored in Ceph
|
||||||
if (file._id && file.extension) {
|
if (file._id && file.extension) {
|
||||||
const cephKey = `files/${id}${file.extension}`;
|
const cephKey = `files/${id}${file.extension}`;
|
||||||
|
logger.info(`Starting Ceph download: ${cephKey} (recorded size: ${file.size ?? 'unknown'})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await downloadFile(BUCKETS.FILES, cephKey);
|
const body = await downloadFile(BUCKETS.FILES, cephKey);
|
||||||
|
logger.debug(`Ceph response stream opened: ${cephKey}`, {
|
||||||
|
readable: body?.readable,
|
||||||
|
destroyed: body?.destroyed,
|
||||||
|
});
|
||||||
|
|
||||||
// Set appropriate content type and disposition
|
// Preserve the stored bytes across reverse proxies. This also repairs
|
||||||
res.set('Content-Type', file.type || 'application/octet-stream');
|
// downloads of existing slicer uploads recorded as text/plain.
|
||||||
|
res.set('Content-Type', getDownloadContentType(file));
|
||||||
res.set('Content-Disposition', `attachment; filename="${file.name}${file.extension}"`);
|
res.set('Content-Disposition', `attachment; filename="${file.name}${file.extension}"`);
|
||||||
|
res.set('Cache-Control', 'private, no-transform');
|
||||||
// Expose file size so clients can compute download progress
|
|
||||||
if (typeof file.size === 'number' && !Number.isNaN(file.size)) {
|
|
||||||
res.set('Content-Length', String(file.size));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream or send buffer
|
// Stream or send buffer
|
||||||
if (body && typeof body.pipe === 'function') {
|
if (body && typeof body.pipe === 'function') {
|
||||||
|
body.on('data', (chunk) => {
|
||||||
|
bytesSent += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
body.on('end', () => {
|
||||||
|
sourceEnded = true;
|
||||||
|
logger.info(`Ceph stream ended: ${cephKey} (${bytesSent} bytes read)`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof file.size === 'number' &&
|
||||||
|
!Number.isNaN(file.size) &&
|
||||||
|
bytesSent !== file.size
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`File size mismatch for ${cephKey}: database=${file.size}, streamed=${bytesSent}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
body.on('close', () => {
|
||||||
|
logger.debug(
|
||||||
|
`Ceph stream closed: ${cephKey} (${bytesSent} bytes read, ended: ${sourceEnded})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Handle stream errors
|
// Handle stream errors
|
||||||
body.on('error', (err) => {
|
body.on('error', (err) => {
|
||||||
logger.error('Error streaming file from Ceph:', err);
|
logger.error(
|
||||||
|
`Error streaming file from Ceph: ${cephKey} (${bytesSent} bytes read)`,
|
||||||
|
err
|
||||||
|
);
|
||||||
// If headers not sent, send a 500; otherwise destroy the response
|
// If headers not sent, send a 500; otherwise destroy the response
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
try {
|
try {
|
||||||
@ -343,7 +413,7 @@ export const getFileContentRouteHandler = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
body.pipe(res);
|
body.pipe(res);
|
||||||
logger.debug('Retrieved:', cephKey);
|
logger.debug(`Ceph stream piped to response: ${cephKey}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,6 +427,7 @@ export const getFileContentRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
// Fallback to local file system for backward compatibility
|
// Fallback to local file system for backward compatibility
|
||||||
const filePath = path.join(config.storage.fileStorage, file.fileName || file.name);
|
const filePath = path.join(config.storage.fileStorage, file.fileName || file.name);
|
||||||
|
logger.info(`Falling back to local file download: ${filePath}`);
|
||||||
|
|
||||||
// Read the file
|
// Read the file
|
||||||
fs.readFile(filePath, (err, data) => {
|
fs.readFile(filePath, (err, data) => {
|
||||||
@ -367,8 +438,13 @@ export const getFileContentRouteHandler = async (req, res) => {
|
|||||||
return res.status(500).send({ error: 'Error reading file.' });
|
return res.status(500).send({ error: 'Error reading file.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.set('Content-Type', file.type || 'application/octet-stream');
|
bytesSent = data.length;
|
||||||
|
sourceEnded = true;
|
||||||
|
logger.info(`Local file read complete: ${filePath} (${data.length} bytes)`);
|
||||||
|
|
||||||
|
res.set('Content-Type', getDownloadContentType(file));
|
||||||
res.set('Content-Disposition', `inline; filename="${file.name}${file.extension || ''}"`);
|
res.set('Content-Disposition', `inline; filename="${file.name}${file.extension || ''}"`);
|
||||||
|
res.set('Cache-Control', 'private, no-transform');
|
||||||
|
|
||||||
// Ensure Content-Length is set for progress events if possible.
|
// Ensure Content-Length is set for progress events if possible.
|
||||||
const length =
|
const length =
|
||||||
|
|||||||
90
src/services/misc/__tests__/sessionStore.test.js
Normal file
90
src/services/misc/__tests__/sessionStore.test.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
const redisMock = {
|
||||||
|
setKey: jest.fn(),
|
||||||
|
getKey: jest.fn(),
|
||||||
|
deleteKey: jest.fn(),
|
||||||
|
getAndDeleteKey: jest.fn(),
|
||||||
|
eval: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../config.js', () => ({
|
||||||
|
default: { server: { logLevel: 'info' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/redis.js', () => ({
|
||||||
|
redisServer: redisMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: 'info',
|
||||||
|
debug: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
createSlicerSession,
|
||||||
|
createSlicerAuthCode,
|
||||||
|
getAndConsumeSlicerAuthCode,
|
||||||
|
isSlicerAuthCode,
|
||||||
|
} = await import('../sessionStore.js');
|
||||||
|
|
||||||
|
describe('slicer sessions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('atomically replaces the current slicer session with a 99-year session', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const user = { _id: 'user-1', username: 'slicer-user', roles: ['user'] };
|
||||||
|
|
||||||
|
const result = await createSlicerSession(user);
|
||||||
|
|
||||||
|
expect(result.sessionToken).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(now + 98 * 365 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(result.expiresAt).toBeLessThan(now + 100 * 366 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(redisMock.eval).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("redis.call('DEL'"),
|
||||||
|
['slicer-session:user-1', `session:${result.sessionToken}`],
|
||||||
|
[
|
||||||
|
expect.stringContaining('"source":"slicer"'),
|
||||||
|
expect.any(Number),
|
||||||
|
result.sessionToken,
|
||||||
|
'session:',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a short-lived one-time auth code containing the session response', async () => {
|
||||||
|
const session = {
|
||||||
|
sessionToken: 'session-token',
|
||||||
|
expiresAt: Date.now() + 1000,
|
||||||
|
user: { _id: 'user-1', username: 'slicer-user' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const code = await createSlicerAuthCode(session);
|
||||||
|
|
||||||
|
expect(isSlicerAuthCode(code)).toBe(true);
|
||||||
|
expect(redisMock.setKey).toHaveBeenCalledWith(
|
||||||
|
`slicer-auth-code:${code}`,
|
||||||
|
{
|
||||||
|
access_token: session.sessionToken,
|
||||||
|
expires_at: session.expiresAt,
|
||||||
|
...session.user,
|
||||||
|
},
|
||||||
|
60
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes an auth code through the atomic Redis operation', async () => {
|
||||||
|
const tokenData = { access_token: 'session-token' };
|
||||||
|
redisMock.getAndDeleteKey.mockResolvedValueOnce(tokenData).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(getAndConsumeSlicerAuthCode('slicer_code')).resolves.toEqual(tokenData);
|
||||||
|
await expect(getAndConsumeSlicerAuthCode('slicer_code')).resolves.toBeNull();
|
||||||
|
expect(redisMock.getAndDeleteKey).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
src/services/misc/__tests__/slicer.test.js
Normal file
141
src/services/misc/__tests__/slicer.test.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
const printerId = '507f1f77bcf86cd799439011';
|
||||||
|
const storedFileId = '507f1f77bcf86cd799439012';
|
||||||
|
const printer = { _id: printerId, pendingSlicerUploads: [] };
|
||||||
|
|
||||||
|
const findById = jest.fn(() => ({
|
||||||
|
select: jest.fn(() => ({
|
||||||
|
lean: jest.fn().mockResolvedValue(printer),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
const newObject = jest.fn();
|
||||||
|
const editObject = jest.fn();
|
||||||
|
const deleteObject = jest.fn();
|
||||||
|
const uploadFile = jest.fn();
|
||||||
|
const deleteFile = jest.fn();
|
||||||
|
const getFileMeta = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../config.js', () => ({
|
||||||
|
default: {
|
||||||
|
server: { logLevel: 'info' },
|
||||||
|
app: { urlClient: 'http://localhost:3000' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/schemas/management/file.schema.js', () => ({
|
||||||
|
fileModel: { modelName: 'file' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/schemas/production/printer.schema.js', () => ({
|
||||||
|
printerModel: { modelName: 'printer', findById },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/database.js', () => ({
|
||||||
|
deleteObject,
|
||||||
|
editObject,
|
||||||
|
newObject,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/ceph.js', () => ({
|
||||||
|
BUCKETS: { FILES: 'files' },
|
||||||
|
deleteFile,
|
||||||
|
uploadFile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../utils.js', () => ({
|
||||||
|
getFileMeta,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../sessionStore.js', () => ({
|
||||||
|
createSlicerAuthCode: jest.fn(),
|
||||||
|
createSlicerSession: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: 'info',
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { slicerUploadRouteHandler } = await import('../slicer.js');
|
||||||
|
|
||||||
|
describe('slicer upload', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
newObject.mockResolvedValue({ _id: storedFileId });
|
||||||
|
editObject.mockResolvedValue({ _id: printerId });
|
||||||
|
getFileMeta.mockResolvedValue({ generator: 'test-slicer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the multipart file bytes and parses OctoPrint boolean fields', async () => {
|
||||||
|
const app = express();
|
||||||
|
app.post(
|
||||||
|
'/slicer/:printerId/user/pass/api/files/local',
|
||||||
|
(req, _res, next) => {
|
||||||
|
req.user = { username: 'slicer-user' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
slicerUploadRouteHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileContents = Buffer.from('G28\nG1 X10 Y20\n');
|
||||||
|
const response = await request(app)
|
||||||
|
.post(`/slicer/${printerId}/user/pass/api/files/local`)
|
||||||
|
.field('select', 'true')
|
||||||
|
.field('print', 'false')
|
||||||
|
.attach('file', fileContents, {
|
||||||
|
filename: 'part.gcode',
|
||||||
|
// Cura and other slicers may identify G-code as compressible text.
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(uploadFile).toHaveBeenCalledWith(
|
||||||
|
'files',
|
||||||
|
`files/${storedFileId}.gcode`,
|
||||||
|
fileContents,
|
||||||
|
'application/octet-stream',
|
||||||
|
{
|
||||||
|
originalName: 'part.gcode',
|
||||||
|
uploadedBy: 'slicer-user',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(newObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
newData: expect.objectContaining({
|
||||||
|
name: 'part',
|
||||||
|
extension: '.gcode',
|
||||||
|
size: fileContents.length,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(editObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
updateData: {
|
||||||
|
pendingSlicerUploads: [
|
||||||
|
expect.objectContaining({
|
||||||
|
file: storedFileId,
|
||||||
|
shouldPrint: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(response.body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
done: true,
|
||||||
|
effectiveSelect: true,
|
||||||
|
effectivePrint: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(response.headers.location).toMatch(/\/part\.gcode$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,6 +12,8 @@ import {
|
|||||||
getSession,
|
getSession,
|
||||||
updateSessionKeycloakTokens,
|
updateSessionKeycloakTokens,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
getAndConsumeSlicerAuthCode,
|
||||||
|
isSlicerAuthCode,
|
||||||
} from './sessionStore.js';
|
} from './sessionStore.js';
|
||||||
import { expandObjectIds } from '../../utils.js';
|
import { expandObjectIds } from '../../utils.js';
|
||||||
|
|
||||||
@ -128,6 +130,15 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isSlicerAuthCode(code)) {
|
||||||
|
const slicerTokenData = await getAndConsumeSlicerAuthCode(code);
|
||||||
|
if (!slicerTokenData) {
|
||||||
|
return res.status(401).json({ error: 'Authorization code invalid or expired' });
|
||||||
|
}
|
||||||
|
logger.debug('Exchanged slicer auth code for session');
|
||||||
|
return res.status(200).json(slicerTokenData);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for temporary email render auth code (30s TTL for Puppeteer)
|
// Check for temporary email render auth code (30s TTL for Puppeteer)
|
||||||
const emailRenderData = getAndConsumeEmailRenderTokenData(code);
|
const emailRenderData = getAndConsumeEmailRenderTokenData(code);
|
||||||
if (emailRenderData) {
|
if (emailRenderData) {
|
||||||
|
|||||||
@ -12,6 +12,20 @@ const logger = log4js.getLogger('SessionStore');
|
|||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
const SESSION_KEY_PREFIX = 'session:';
|
const SESSION_KEY_PREFIX = 'session:';
|
||||||
|
const SLICER_SESSION_KEY_PREFIX = 'slicer-session:';
|
||||||
|
const SLICER_AUTH_CODE_KEY_PREFIX = 'slicer-auth-code:';
|
||||||
|
const SLICER_AUTH_CODE_PREFIX = 'slicer_';
|
||||||
|
const SLICER_AUTH_CODE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
const REPLACE_SLICER_SESSION_SCRIPT = `
|
||||||
|
local previousToken = redis.call('GET', KEYS[1])
|
||||||
|
redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2])
|
||||||
|
redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[2])
|
||||||
|
if previousToken and previousToken ~= ARGV[3] then
|
||||||
|
redis.call('DEL', ARGV[4] .. previousToken)
|
||||||
|
end
|
||||||
|
return previousToken
|
||||||
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a cryptographically secure session token
|
* Generate a cryptographically secure session token
|
||||||
@ -61,6 +75,65 @@ export async function createSession({ user, keycloakTokens }) {
|
|||||||
return { sessionToken, expiresAt };
|
return { sessionToken, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a long-lived slicer UI session, replacing only the user's previous slicer session.
|
||||||
|
*/
|
||||||
|
export async function createSlicerSession(user) {
|
||||||
|
const sessionToken = generateSessionToken();
|
||||||
|
const expiresAtDate = new Date();
|
||||||
|
expiresAtDate.setFullYear(expiresAtDate.getFullYear() + 99);
|
||||||
|
const expiresAt = expiresAtDate.getTime();
|
||||||
|
const ttlSeconds = getTtlSeconds(expiresAt);
|
||||||
|
const sessionData = {
|
||||||
|
sessionToken,
|
||||||
|
user: userToSessionUser(user),
|
||||||
|
keycloakTokens: null,
|
||||||
|
expiresAt,
|
||||||
|
source: 'slicer',
|
||||||
|
};
|
||||||
|
console.log('sessionData', sessionData);
|
||||||
|
const userId = sessionData.user?._id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('Cannot create slicer session without a user id');
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisServer.eval(
|
||||||
|
REPLACE_SLICER_SESSION_SCRIPT,
|
||||||
|
[SLICER_SESSION_KEY_PREFIX + userId, SESSION_KEY_PREFIX + sessionToken],
|
||||||
|
[JSON.stringify(sessionData), ttlSeconds, sessionToken, SESSION_KEY_PREFIX]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(`Created replacement slicer session for user ${user.username}`);
|
||||||
|
return { sessionToken, expiresAt, user: sessionData.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a short-lived, one-time code which the UI can exchange for a slicer session.
|
||||||
|
*/
|
||||||
|
export async function createSlicerAuthCode({ sessionToken, expiresAt, user }) {
|
||||||
|
const authCode = SLICER_AUTH_CODE_PREFIX + generateSessionToken();
|
||||||
|
await redisServer.setKey(
|
||||||
|
SLICER_AUTH_CODE_KEY_PREFIX + authCode,
|
||||||
|
{
|
||||||
|
access_token: sessionToken,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
...user,
|
||||||
|
},
|
||||||
|
SLICER_AUTH_CODE_TTL_SECONDS
|
||||||
|
);
|
||||||
|
return authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSlicerAuthCode(code) {
|
||||||
|
return typeof code === 'string' && code.startsWith(SLICER_AUTH_CODE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAndConsumeSlicerAuthCode(code) {
|
||||||
|
if (!isSlicerAuthCode(code)) return null;
|
||||||
|
return redisServer.getAndDeleteKey(SLICER_AUTH_CODE_KEY_PREFIX + code);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get session by token. Returns null if not found or expired.
|
* Get session by token. Returns null if not found or expired.
|
||||||
*/
|
*/
|
||||||
|
|||||||
229
src/services/misc/slicer.js
Normal file
229
src/services/misc/slicer.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import config from '../../config.js';
|
||||||
|
import { fileModel } from '../../database/schemas/management/file.schema.js';
|
||||||
|
import { printerModel } from '../../database/schemas/production/printer.schema.js';
|
||||||
|
import { deleteObject, editObject, newObject } from '../../database/database.js';
|
||||||
|
import { BUCKETS, deleteFile as deleteCephFile, uploadFile } from '../../database/ceph.js';
|
||||||
|
import { getFileMeta } from '../../utils.js';
|
||||||
|
import { createSlicerAuthCode, createSlicerSession } from './sessionStore.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('Slicer');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const slicerFileUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 500000000 },
|
||||||
|
}).single('file');
|
||||||
|
|
||||||
|
const receiveSlicerFile = (req, res) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
slicerFileUpload(req, res, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseFormBoolean = (value) =>
|
||||||
|
value === true || (typeof value === 'string' && value.toLowerCase() === 'true');
|
||||||
|
|
||||||
|
const getSlicerContentType = (extension, reportedContentType) => {
|
||||||
|
// Slicers commonly label G-code as text/plain. That allows reverse proxies
|
||||||
|
// to compress the response later, which can invalidate a known byte length.
|
||||||
|
if (extension.toLowerCase() === '.gcode') {
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
return reportedContentType || 'application/octet-stream';
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLICER_VERSION = {
|
||||||
|
server: '1.5.0',
|
||||||
|
api: '0.1',
|
||||||
|
text: 'OctoPrint (Moonraker v0.9.3-72-g7cdcca3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slicerUIRouteHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { printerId } = req.params;
|
||||||
|
console.log('req.user', req.user);
|
||||||
|
const session = await createSlicerSession(req.user);
|
||||||
|
const authCode = await createSlicerAuthCode(session);
|
||||||
|
const uiUrl = new URL('/slicer', config.app.urlClient);
|
||||||
|
uiUrl.searchParams.set('printerId', printerId);
|
||||||
|
uiUrl.searchParams.set('authCode', authCode);
|
||||||
|
return res.redirect(uiUrl.toString());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create slicer UI session:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to authenticate slicer UI' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slicerVersionRouteHandler = async (req, res) => {
|
||||||
|
const { printerId } = req.params;
|
||||||
|
|
||||||
|
if (!mongoose.isValidObjectId(printerId)) {
|
||||||
|
return res.status(404).send({ error: 'Printer not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const printer = await mongoose.connection
|
||||||
|
.collection('printers')
|
||||||
|
.findOne({ _id: new mongoose.Types.ObjectId(printerId) }, { projection: { _id: 1 } });
|
||||||
|
|
||||||
|
if (!printer) {
|
||||||
|
return res.status(404).send({ error: 'Printer not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(SLICER_VERSION);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slicerUploadRouteHandler = async (req, res) => {
|
||||||
|
const { printerId } = req.params;
|
||||||
|
|
||||||
|
if (!mongoose.isValidObjectId(printerId)) {
|
||||||
|
return res.status(404).send({ error: 'Printer not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const printer = await printerModel.findById(printerId).select('_id pendingSlicerUploads').lean();
|
||||||
|
if (!printer) {
|
||||||
|
return res.status(404).send({ error: 'Printer not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await receiveSlicerFile(req, res);
|
||||||
|
} catch (uploadError) {
|
||||||
|
logger.error('Error receiving slicer upload:', uploadError);
|
||||||
|
const status = uploadError.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||||
|
return res.status(status).send({ error: uploadError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// OctoPrint uploads use a multipart `file` part. Multer places the binary
|
||||||
|
// part on req.file and the accompanying select/print fields on req.body.
|
||||||
|
const file = req.file;
|
||||||
|
if (!file?.buffer || !file.originalname) {
|
||||||
|
return res.status(400).send({ error: 'No file selected.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = path.basename(file.originalname);
|
||||||
|
const extension = path.extname(originalName);
|
||||||
|
const baseName = path.parse(originalName).name;
|
||||||
|
|
||||||
|
if (!extension || !baseName) {
|
||||||
|
return res.status(400).send({ error: 'The uploaded file must have a valid filename.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPrint = parseFormBoolean(req.body?.print);
|
||||||
|
const shouldSelect = shouldPrint || parseFormBoolean(req.body?.select);
|
||||||
|
const contentType = getSlicerContentType(extension, file.mimetype);
|
||||||
|
let createdFile;
|
||||||
|
let cephKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meta = await getFileMeta({ ...file, originalname: originalName });
|
||||||
|
|
||||||
|
createdFile = await newObject({
|
||||||
|
model: fileModel,
|
||||||
|
newData: {
|
||||||
|
name: baseName,
|
||||||
|
type: contentType,
|
||||||
|
extension,
|
||||||
|
size: file.buffer.length,
|
||||||
|
metaData: {
|
||||||
|
originalName,
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createdFile.error) {
|
||||||
|
logger.error('Error creating slicer file:', createdFile.error);
|
||||||
|
return res.status(createdFile.code || 500).send(createdFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
cephKey = `files/${createdFile._id}${extension}`;
|
||||||
|
await uploadFile(BUCKETS.FILES, cephKey, file.buffer, contentType, {
|
||||||
|
originalName,
|
||||||
|
uploadedBy: req.user?.username || 'slicer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updatedPrinter = await editObject({
|
||||||
|
model: printerModel,
|
||||||
|
id: printerId,
|
||||||
|
updateData: {
|
||||||
|
pendingSlicerUploads: [
|
||||||
|
...(printer.pendingSlicerUploads || []),
|
||||||
|
{
|
||||||
|
file: createdFile,
|
||||||
|
gcodeFile: null,
|
||||||
|
shouldPrint,
|
||||||
|
new: true,
|
||||||
|
properties: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedPrinter.error) {
|
||||||
|
throw new Error(updatedPrinter.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Slicer file ${createdFile._id} uploaded and queued for printer ${printerId}`);
|
||||||
|
|
||||||
|
const uploadPath = (req.originalUrl || req.url).split('?')[0].replace(/\/$/, '');
|
||||||
|
const resource = `${uploadPath}/${encodeURIComponent(originalName)}`;
|
||||||
|
res.location(resource);
|
||||||
|
|
||||||
|
return res.status(201).send({
|
||||||
|
files: {
|
||||||
|
local: {
|
||||||
|
name: originalName,
|
||||||
|
origin: 'local',
|
||||||
|
path: originalName,
|
||||||
|
type: 'machinecode',
|
||||||
|
typePath: ['machinecode', extension.slice(1).toLowerCase()],
|
||||||
|
refs: {
|
||||||
|
resource,
|
||||||
|
download: resource,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
done: true,
|
||||||
|
effectiveSelect: shouldSelect,
|
||||||
|
effectivePrint: shouldPrint,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing slicer upload:', error);
|
||||||
|
|
||||||
|
if (cephKey) {
|
||||||
|
try {
|
||||||
|
await deleteCephFile(BUCKETS.FILES, cephKey);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error('Error cleaning up slicer file from storage:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdFile?._id) {
|
||||||
|
try {
|
||||||
|
await deleteObject({
|
||||||
|
model: fileModel,
|
||||||
|
id: createdFile._id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error('Error cleaning up slicer file record:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
137
src/services/production/__tests__/filamentprofiles.test.js
Normal file
137
src/services/production/__tests__/filamentprofiles.test.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/database.js', () => ({
|
||||||
|
deleteObject: jest.fn(),
|
||||||
|
editObject: jest.fn(),
|
||||||
|
getObject: jest.fn(),
|
||||||
|
listObjects: jest.fn(),
|
||||||
|
listObjectsByProperties: jest.fn(),
|
||||||
|
newObject: jest.fn(),
|
||||||
|
searchObjects: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/schemas/production/filamentprofile.schema.js', () => ({
|
||||||
|
filamentProfileModel: { modelName: 'filamentProfile' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: 'info',
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
deleteFilamentProfileRouteHandler,
|
||||||
|
editFilamentProfileRouteHandler,
|
||||||
|
listFilamentProfilesRouteHandler,
|
||||||
|
newFilamentProfileRouteHandler,
|
||||||
|
} = await import('../filamentprofiles.js');
|
||||||
|
const { deleteObject, editObject, listObjects, newObject } =
|
||||||
|
await import('../../../database/database.js');
|
||||||
|
const { filamentProfileModel } =
|
||||||
|
await import('../../../database/schemas/production/filamentprofile.schema.js');
|
||||||
|
|
||||||
|
describe('Filament profile service route handlers', () => {
|
||||||
|
let req;
|
||||||
|
let res;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
params: {},
|
||||||
|
body: {},
|
||||||
|
user: { id: 'test-user-id' },
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
send: jest.fn(),
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists filament profiles', async () => {
|
||||||
|
const result = [{ _id: '1', name: 'PETG profile' }];
|
||||||
|
listObjects.mockResolvedValue(result);
|
||||||
|
|
||||||
|
await listFilamentProfilesRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(listObjects).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
populate: ['filament', 'compatiblePrinters'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates profiles using only supported Orca fields', async () => {
|
||||||
|
const profileBody = {
|
||||||
|
name: 'Prusa Generic PETG',
|
||||||
|
filamentType: 'filament',
|
||||||
|
filament: '507f1f77bcf86cd799439011',
|
||||||
|
filamentFlowRatio: 0.98,
|
||||||
|
enablePressureAdvance: false,
|
||||||
|
pressureAdvance: 0.02,
|
||||||
|
adaptivePressureAdvance: false,
|
||||||
|
nozzleTemperatureInitialLayer: 230,
|
||||||
|
nozzleTemperature: 240,
|
||||||
|
hotPlateTempInitialLayer: 85,
|
||||||
|
hotPlateTemp: 85,
|
||||||
|
filamentAdaptiveVolumetricSpeed: false,
|
||||||
|
filamentMaxVolumetricSpeed: 8,
|
||||||
|
closeFanTheFirstXLayers: 3,
|
||||||
|
fanMinSpeed: 40,
|
||||||
|
fanMaxSpeed: 90,
|
||||||
|
activateAirFiltration: false,
|
||||||
|
compatiblePrinters: ['507f1f77bcf86cd799439012'],
|
||||||
|
};
|
||||||
|
req.body = { ...profileBody, unexpected: 'ignored' };
|
||||||
|
newObject.mockImplementation(async ({ newData }) => ({ _id: 'profile-id', ...newData }));
|
||||||
|
|
||||||
|
await newFilamentProfileRouteHandler(req, res);
|
||||||
|
|
||||||
|
const { newData } = newObject.mock.calls[0][0];
|
||||||
|
expect(newData).toEqual(profileBody);
|
||||||
|
expect(newData).not.toHaveProperty('unexpected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits only supplied supported Orca fields', async () => {
|
||||||
|
req.params.id = '507f1f77bcf86cd799439011';
|
||||||
|
req.body = {
|
||||||
|
name: 'Updated PETG profile',
|
||||||
|
filamentFlowRatio: 1,
|
||||||
|
unexpected: 'ignored',
|
||||||
|
};
|
||||||
|
editObject.mockResolvedValue({ _id: req.params.id, name: req.body.name });
|
||||||
|
|
||||||
|
await editFilamentProfileRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(editObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
name: 'Updated PETG profile',
|
||||||
|
filamentFlowRatio: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes profiles', async () => {
|
||||||
|
req.params.id = '507f1f77bcf86cd799439011';
|
||||||
|
const result = { _id: req.params.id };
|
||||||
|
deleteObject.mockResolvedValue(result);
|
||||||
|
|
||||||
|
await deleteFilamentProfileRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(deleteObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ model: filamentProfileModel, user: req.user })
|
||||||
|
);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
158
src/services/production/__tests__/printerprofiles.test.js
Normal file
158
src/services/production/__tests__/printerprofiles.test.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/database.js', () => ({
|
||||||
|
deleteObject: jest.fn(),
|
||||||
|
editObject: jest.fn(),
|
||||||
|
getObject: jest.fn(),
|
||||||
|
listObjects: jest.fn(),
|
||||||
|
listObjectsByProperties: jest.fn(),
|
||||||
|
newObject: jest.fn(),
|
||||||
|
searchObjects: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/schemas/production/printerprofile.schema.js', () => ({
|
||||||
|
printerProfileModel: { modelName: 'printerProfile' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.unstable_mockModule('log4js', () => ({
|
||||||
|
default: {
|
||||||
|
getLogger: () => ({
|
||||||
|
level: 'info',
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
deletePrinterProfileRouteHandler,
|
||||||
|
editPrinterProfileRouteHandler,
|
||||||
|
listPrinterProfilesRouteHandler,
|
||||||
|
newPrinterProfileRouteHandler,
|
||||||
|
} = await import('../printerprofiles.js');
|
||||||
|
const { deleteObject, editObject, listObjects, newObject } =
|
||||||
|
await import('../../../database/database.js');
|
||||||
|
const { printerProfileModel } =
|
||||||
|
await import('../../../database/schemas/production/printerprofile.schema.js');
|
||||||
|
|
||||||
|
describe('Printer profile service route handlers', () => {
|
||||||
|
let req;
|
||||||
|
let res;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
params: {},
|
||||||
|
body: {},
|
||||||
|
user: { id: 'test-user-id' },
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
send: jest.fn(),
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists printer profiles', async () => {
|
||||||
|
const result = [{ _id: '1', name: 'Orca profile' }];
|
||||||
|
listObjects.mockResolvedValue(result);
|
||||||
|
|
||||||
|
await listPrinterProfilesRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(listObjects).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ model: printerProfileModel, populate: ['printer'] })
|
||||||
|
);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates profiles using only supported Orca fields', async () => {
|
||||||
|
const profileBody = {
|
||||||
|
name: 'Orca profile',
|
||||||
|
printer: '507f1f77bcf86cd799439011',
|
||||||
|
extruders: [
|
||||||
|
{
|
||||||
|
nozzleDiameter: 0.4,
|
||||||
|
nozzleType: 'hardened_steel',
|
||||||
|
minLayerHeight: 0.08,
|
||||||
|
maxLayerHeight: 0.3,
|
||||||
|
positionOffsetX: 0,
|
||||||
|
positionOffsetY: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
printBedWidth: 220,
|
||||||
|
printBedHeight: 220,
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
bedExcludeArea: [{ x: 0, y: 0 }],
|
||||||
|
printableHeight: 250,
|
||||||
|
supportMultiBedTypes: false,
|
||||||
|
bestObjectPositionX: 110,
|
||||||
|
bestObjectPositionY: 110,
|
||||||
|
zOffset: 0,
|
||||||
|
preferredOrientation: 0,
|
||||||
|
machineMaxAccelerationRetracting: { min: 5000, max: 5000 },
|
||||||
|
machineMaxSpeedE: { min: 30, max: 30 },
|
||||||
|
machineMaxSpeedX: { min: 500, max: 500 },
|
||||||
|
machineMaxSpeedY: { min: 500, max: 500 },
|
||||||
|
machinePauseGcode: 'PAUSE',
|
||||||
|
machineStartGcode: 'START_PRINT',
|
||||||
|
machineEndGcode: 'END_PRINT',
|
||||||
|
layerChangeGcode: 'LAYER_CHANGE',
|
||||||
|
beforeLayerChangeGcode: 'BEFORE_LAYER_CHANGE',
|
||||||
|
printerNotes: 'Test printer',
|
||||||
|
scanFirstLayer: true,
|
||||||
|
machineLoadFilamentTime: 20,
|
||||||
|
machineUnloadFilamentTime: 15,
|
||||||
|
thumbnails: [
|
||||||
|
{ width: 32, height: 32 },
|
||||||
|
{ width: 300, height: 300 }
|
||||||
|
],
|
||||||
|
auxiliaryFan: true,
|
||||||
|
machineMaxJunctionDeviation: { min: 0.02, max: 0.02 },
|
||||||
|
};
|
||||||
|
req.body = { ...profileBody, unexpected: 'ignored' };
|
||||||
|
newObject.mockImplementation(async ({ newData }) => ({ _id: 'profile-id', ...newData }));
|
||||||
|
|
||||||
|
await newPrinterProfileRouteHandler(req, res);
|
||||||
|
|
||||||
|
const { newData } = newObject.mock.calls[0][0];
|
||||||
|
expect(newData).toEqual(profileBody);
|
||||||
|
expect(newData).not.toHaveProperty('unexpected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits only supplied supported Orca fields', async () => {
|
||||||
|
req.params.id = '507f1f77bcf86cd799439011';
|
||||||
|
req.body = {
|
||||||
|
name: 'Updated profile',
|
||||||
|
extruders: [{ nozzleDiameter: 0.4 }],
|
||||||
|
unexpected: 'ignored',
|
||||||
|
};
|
||||||
|
editObject.mockResolvedValue({ _id: req.params.id, name: req.body.name });
|
||||||
|
|
||||||
|
await editPrinterProfileRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(editObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: printerProfileModel,
|
||||||
|
updateData: {
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
name: 'Updated profile',
|
||||||
|
extruders: [{ nozzleDiameter: 0.4 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes profiles', async () => {
|
||||||
|
req.params.id = '507f1f77bcf86cd799439011';
|
||||||
|
const result = { _id: req.params.id };
|
||||||
|
deleteObject.mockResolvedValue(result);
|
||||||
|
|
||||||
|
await deletePrinterProfileRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(deleteObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ model: printerProfileModel, user: req.user })
|
||||||
|
);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -65,20 +65,32 @@ describe('Printer Service Route Handlers', () => {
|
|||||||
|
|
||||||
await listPrintersRouteHandler(req, res);
|
await listPrintersRouteHandler(req, res);
|
||||||
|
|
||||||
expect(listObjects).toHaveBeenCalledWith(expect.objectContaining({ model: printerModel }));
|
expect(listObjects).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
model: printerModel,
|
||||||
|
populate: ['host'],
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(res.send).toHaveBeenCalledWith(mockResult);
|
expect(res.send).toHaveBeenCalledWith(mockResult);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('newPrinterRouteHandler', () => {
|
describe('newPrinterRouteHandler', () => {
|
||||||
it('should create a new printer', async () => {
|
it('should create a new printer', async () => {
|
||||||
req.body = { name: 'New Printer', host: 'host123' };
|
req.body = {
|
||||||
|
name: 'New Printer',
|
||||||
|
host: 'host123',
|
||||||
|
};
|
||||||
const mockPrinter = { _id: '456', ...req.body };
|
const mockPrinter = { _id: '456', ...req.body };
|
||||||
newObject.mockResolvedValue(mockPrinter);
|
newObject.mockResolvedValue(mockPrinter);
|
||||||
|
|
||||||
await newPrinterRouteHandler(req, res);
|
await newPrinterRouteHandler(req, res);
|
||||||
|
|
||||||
expect(newObject).toHaveBeenCalled();
|
expect(newObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
newData: expect.objectContaining({ host: 'host123' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(res.send).toHaveBeenCalledWith(mockPrinter);
|
expect(res.send).toHaveBeenCalledWith(mockPrinter);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -86,13 +98,18 @@ describe('Printer Service Route Handlers', () => {
|
|||||||
describe('editPrinterRouteHandler', () => {
|
describe('editPrinterRouteHandler', () => {
|
||||||
it('should update a printer', async () => {
|
it('should update a printer', async () => {
|
||||||
req.params.id = '507f1f77bcf86cd799439011';
|
req.params.id = '507f1f77bcf86cd799439011';
|
||||||
req.body = { name: 'Updated Printer' };
|
req.body = { name: 'Updated Printer', host: 'host456' };
|
||||||
const mockResult = { _id: '507f1f77bcf86cd799439011', ...req.body };
|
const mockResult = { _id: '507f1f77bcf86cd799439011', ...req.body };
|
||||||
editObject.mockResolvedValue(mockResult);
|
editObject.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
await editPrinterRouteHandler(req, res);
|
await editPrinterRouteHandler(req, res);
|
||||||
|
|
||||||
expect(editObject).toHaveBeenCalled();
|
expect(editObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
updateData: expect.objectContaining({ host: 'host456' }),
|
||||||
|
populate: ['vendor', 'host'],
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(res.send).toHaveBeenCalledWith(mockResult);
|
expect(res.send).toHaveBeenCalledWith(mockResult);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
305
src/services/production/filamentprofiles.js
Normal file
305
src/services/production/filamentprofiles.js
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
import config from '../../config.js';
|
||||||
|
import { filamentProfileModel } from '../../database/schemas/production/filamentprofile.schema.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
editObject,
|
||||||
|
getObject,
|
||||||
|
listObjects,
|
||||||
|
listObjectsByProperties,
|
||||||
|
newObject,
|
||||||
|
searchObjects,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('FilamentProfiles');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
const FILAMENT_PROFILE_POPULATE = ['filament', 'compatiblePrinters'];
|
||||||
|
|
||||||
|
export const filamentProfileFields = [
|
||||||
|
'name',
|
||||||
|
'filamentType',
|
||||||
|
'filament',
|
||||||
|
'filamentIsSupport',
|
||||||
|
'filamentSoluble',
|
||||||
|
'filamentPrintable',
|
||||||
|
'filamentAdhesivenessCategory',
|
||||||
|
'temperatureVitrification',
|
||||||
|
'idleTemperature',
|
||||||
|
'pelletFlowCoefficient',
|
||||||
|
'requiredNozzleHrc',
|
||||||
|
'filamentFlowRatio',
|
||||||
|
'enablePressureAdvance',
|
||||||
|
'pressureAdvance',
|
||||||
|
'adaptivePressureAdvance',
|
||||||
|
'adaptivePressureAdvanceBridges',
|
||||||
|
'adaptivePressureAdvanceOverhangs',
|
||||||
|
'adaptivePressureAdvanceModel',
|
||||||
|
'activateChamberTempControl',
|
||||||
|
'chamberTemperature',
|
||||||
|
'chamberMinimalTemperature',
|
||||||
|
'nozzleTemperatureInitialLayer',
|
||||||
|
'nozzleTemperature',
|
||||||
|
'nozzleTemperatureRangeLow',
|
||||||
|
'nozzleTemperatureRangeHigh',
|
||||||
|
'hotPlateTempInitialLayer',
|
||||||
|
'hotPlateTemp',
|
||||||
|
'coolPlateTempInitialLayer',
|
||||||
|
'coolPlateTemp',
|
||||||
|
'engPlateTempInitialLayer',
|
||||||
|
'engPlateTemp',
|
||||||
|
'texturedPlateTempInitialLayer',
|
||||||
|
'texturedPlateTemp',
|
||||||
|
'texturedCoolPlateTempInitialLayer',
|
||||||
|
'texturedCoolPlateTemp',
|
||||||
|
'supertackPlateTempInitialLayer',
|
||||||
|
'supertackPlateTemp',
|
||||||
|
'filamentAdaptiveVolumetricSpeed',
|
||||||
|
'filamentMaxVolumetricSpeed',
|
||||||
|
'volumetricSpeedCoefficients',
|
||||||
|
'closeFanTheFirstXLayers',
|
||||||
|
'fullFanSpeedLayer',
|
||||||
|
'fanMinSpeed',
|
||||||
|
'fanMaxSpeed',
|
||||||
|
'reduceFanStopStartFreq',
|
||||||
|
'slowDownForLayerCooling',
|
||||||
|
'dontSlowDownOuterWall',
|
||||||
|
'slowDownMinSpeed',
|
||||||
|
'slowDownLayerTime',
|
||||||
|
'fanCoolingLayerTime',
|
||||||
|
'enableOverhangBridgeFan',
|
||||||
|
'overhangFanThreshold',
|
||||||
|
'overhangFanSpeed',
|
||||||
|
'internalBridgeFanSpeed',
|
||||||
|
'supportMaterialInterfaceFanSpeed',
|
||||||
|
'ironingFanSpeed',
|
||||||
|
'initialLayerFanSpeed',
|
||||||
|
'firstXLayerFanSpeed',
|
||||||
|
'additionalCoolingFanSpeed',
|
||||||
|
'additionalFanFullSpeedLayer',
|
||||||
|
'closeAdditionalFanFirstXLayers',
|
||||||
|
'activateAirFiltration',
|
||||||
|
'activateAirFiltrationDuringPrint',
|
||||||
|
'activateAirFiltrationOnCompletion',
|
||||||
|
'duringPrintExhaustFanSpeed',
|
||||||
|
'completePrintExhaustFanSpeed',
|
||||||
|
'filamentRetractionLength',
|
||||||
|
'filamentRetractionSpeed',
|
||||||
|
'filamentDeretractionSpeed',
|
||||||
|
'filamentRetractionMinimumTravel',
|
||||||
|
'filamentRetractWhenChangingLayer',
|
||||||
|
'filamentRetractBeforeWipe',
|
||||||
|
'filamentRetractAfterWipe',
|
||||||
|
'filamentRetractRestartExtra',
|
||||||
|
'filamentRetractLiftAbove',
|
||||||
|
'filamentRetractLiftBelow',
|
||||||
|
'filamentRetractLiftEnforce',
|
||||||
|
'filamentWipe',
|
||||||
|
'filamentWipeDistance',
|
||||||
|
'filamentZHop',
|
||||||
|
'filamentZHopTypes',
|
||||||
|
'filamentLongRetractionsWhenCut',
|
||||||
|
'filamentRetractionDistancesWhenCut',
|
||||||
|
'longRetractionsWhenEc',
|
||||||
|
'retractionDistancesWhenEc',
|
||||||
|
'filamentLoadingSpeed',
|
||||||
|
'filamentLoadingSpeedStart',
|
||||||
|
'filamentUnloadingSpeed',
|
||||||
|
'filamentUnloadingSpeedStart',
|
||||||
|
'filamentChangeLength',
|
||||||
|
'filamentChangeLengthNc',
|
||||||
|
'filamentToolchangeDelay',
|
||||||
|
'filamentExtruderCompatibility',
|
||||||
|
'filamentExtruderVariant',
|
||||||
|
'filamentMultitoolRamming',
|
||||||
|
'filamentMultitoolRammingFlow',
|
||||||
|
'filamentMultitoolRammingVolume',
|
||||||
|
'filamentRammingParameters',
|
||||||
|
'filamentRammingTravelTime',
|
||||||
|
'filamentRammingTravelTimeNc',
|
||||||
|
'filamentRammingVolumetricSpeed',
|
||||||
|
'filamentRammingVolumetricSpeedNc',
|
||||||
|
'filamentMinimalPurgeOnWipeTower',
|
||||||
|
'filamentTowerInterfacePreExtrusionDist',
|
||||||
|
'filamentTowerInterfacePreExtrusionLength',
|
||||||
|
'filamentTowerInterfacePrintTemp',
|
||||||
|
'filamentTowerInterfacePurgeVolume',
|
||||||
|
'filamentTowerIroningArea',
|
||||||
|
'filamentCoolingBeforeTower',
|
||||||
|
'filamentCoolingInitialSpeed',
|
||||||
|
'filamentCoolingFinalSpeed',
|
||||||
|
'filamentCoolingMoves',
|
||||||
|
'filamentFlushTemp',
|
||||||
|
'filamentFlushTempFast',
|
||||||
|
'filamentFlushVolumetricSpeed',
|
||||||
|
'filamentPreCoolingTemperature',
|
||||||
|
'filamentPreCoolingTemperatureNc',
|
||||||
|
'filamentPreheatTemperatureDelta',
|
||||||
|
'filamentPrimeVolumeNc',
|
||||||
|
'filamentRetractLengthNc',
|
||||||
|
'filamentStampingDistance',
|
||||||
|
'filamentStampingLoadingSpeed',
|
||||||
|
'filamentStartGcode',
|
||||||
|
'filamentEndGcode',
|
||||||
|
'filamentChangeExtrusionRoleGcode',
|
||||||
|
'filamentShrink',
|
||||||
|
'filamentShrinkageCompensationZ',
|
||||||
|
'filamentDevAmsDryingTemperature',
|
||||||
|
'filamentDevAmsDryingTime',
|
||||||
|
'filamentDevAmsDryingHeatDistortionTemperature',
|
||||||
|
'filamentDevAmsDryingAmsLimitations',
|
||||||
|
'filamentDevChamberDryingBedTemperature',
|
||||||
|
'filamentDevChamberDryingTime',
|
||||||
|
'filamentDevDryingCoolingTemperature',
|
||||||
|
'filamentDevDryingSofteningTemperature',
|
||||||
|
'compatiblePrinters',
|
||||||
|
'compatiblePrintersCondition',
|
||||||
|
'compatiblePrints',
|
||||||
|
'compatiblePrintsCondition',
|
||||||
|
'filamentNotes',
|
||||||
|
];
|
||||||
|
|
||||||
|
const pickFilamentProfileFields = (body = {}) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
filamentProfileFields
|
||||||
|
.filter((field) => Object.hasOwn(body, field))
|
||||||
|
.map((field) => [field, body[field]])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const listFilamentProfilesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: FILAMENT_PROFILE_POPULATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing filament profiles.');
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of filament profiles (Page ${page}, Limit ${limit}). Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listFilamentProfilesByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = [],
|
||||||
|
filter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: FILAMENT_PROFILE_POPULATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing filament profiles.');
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of filament profiles. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchFilamentProfilesRouteHandler = async (req, res, search) => {
|
||||||
|
const result = await searchObjects({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFilamentProfileRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
id,
|
||||||
|
populate: FILAMENT_PROFILE_POPULATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn('Filament profile not found with supplied id.');
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Retrieved filament profile with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editFilamentProfileRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...pickFilamentProfileFields(req.body),
|
||||||
|
};
|
||||||
|
const result = await editObject({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error editing filament profile:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited filament profile with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newFilamentProfileRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
...pickFilamentProfileFields(req.body),
|
||||||
|
};
|
||||||
|
const result = await newObject({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('No filament profile created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New filament profile with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFilamentProfileRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: filamentProfileModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('No filament profile deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted filament profile with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
193
src/services/production/printerprofiles.js
Normal file
193
src/services/production/printerprofiles.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import config from '../../config.js';
|
||||||
|
import { printerProfileModel } from '../../database/schemas/production/printerprofile.schema.js';
|
||||||
|
import log4js from 'log4js';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
deleteObject,
|
||||||
|
editObject,
|
||||||
|
getObject,
|
||||||
|
listObjects,
|
||||||
|
listObjectsByProperties,
|
||||||
|
newObject,
|
||||||
|
searchObjects,
|
||||||
|
} from '../../database/database.js';
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('PrinterProfiles');
|
||||||
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
|
export const printerProfileFields = [
|
||||||
|
'name',
|
||||||
|
'printer',
|
||||||
|
'extruders',
|
||||||
|
'printBedWidth',
|
||||||
|
'printBedHeight',
|
||||||
|
'originX',
|
||||||
|
'originY',
|
||||||
|
'bedExcludeArea',
|
||||||
|
'printableHeight',
|
||||||
|
'supportMultiBedTypes',
|
||||||
|
'bestObjectPositionX',
|
||||||
|
'bestObjectPositionY',
|
||||||
|
'zOffset',
|
||||||
|
'preferredOrientation',
|
||||||
|
'machineMaxAccelerationRetracting',
|
||||||
|
'machineMaxSpeedE',
|
||||||
|
'machineMaxSpeedX',
|
||||||
|
'machineMaxSpeedY',
|
||||||
|
'machinePauseGcode',
|
||||||
|
'machineStartGcode',
|
||||||
|
'machineEndGcode',
|
||||||
|
'layerChangeGcode',
|
||||||
|
'beforeLayerChangeGcode',
|
||||||
|
'printerNotes',
|
||||||
|
'scanFirstLayer',
|
||||||
|
'machineLoadFilamentTime',
|
||||||
|
'machineUnloadFilamentTime',
|
||||||
|
'thumbnails',
|
||||||
|
'auxiliaryFan',
|
||||||
|
'machineMaxJunctionDeviation',
|
||||||
|
];
|
||||||
|
|
||||||
|
const pickPrinterProfileFields = (body = {}) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
printerProfileFields
|
||||||
|
.filter((field) => Object.hasOwn(body, field))
|
||||||
|
.map((field) => [field, body[field]])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const listPrinterProfilesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
property = '',
|
||||||
|
filter = {},
|
||||||
|
search = '',
|
||||||
|
sort = '',
|
||||||
|
order = 'ascend'
|
||||||
|
) => {
|
||||||
|
const result = await listObjects({
|
||||||
|
model: printerProfileModel,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
property,
|
||||||
|
filter,
|
||||||
|
search,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
populate: ['printer'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing printer profiles.');
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of printer profiles (Page ${page}, Limit ${limit}). Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listPrinterProfilesByPropertiesRouteHandler = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
properties = [],
|
||||||
|
filter = {}
|
||||||
|
) => {
|
||||||
|
const result = await listObjectsByProperties({
|
||||||
|
model: printerProfileModel,
|
||||||
|
properties,
|
||||||
|
filter,
|
||||||
|
populate: ['printer'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error listing printer profiles.');
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`List of printer profiles. Count: ${result.length}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchPrinterProfilesRouteHandler = async (req, res, search) => {
|
||||||
|
const result = await searchObjects({
|
||||||
|
model: printerProfileModel,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrinterProfileRouteHandler = async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const result = await getObject({
|
||||||
|
model: printerProfileModel,
|
||||||
|
id,
|
||||||
|
populate: ['printer'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.warn('Printer profile not found with supplied id.');
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Retrieved printer profile with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editPrinterProfileRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...pickPrinterProfileFields(req.body),
|
||||||
|
};
|
||||||
|
const result = await editObject({
|
||||||
|
model: printerProfileModel,
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('Error editing printer profile:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Edited printer profile with ID: ${id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const newPrinterProfileRouteHandler = async (req, res) => {
|
||||||
|
const newData = {
|
||||||
|
...pickPrinterProfileFields(req.body),
|
||||||
|
};
|
||||||
|
const result = await newObject({
|
||||||
|
model: printerProfileModel,
|
||||||
|
newData,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('No printer profile created:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`New printer profile with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePrinterProfileRouteHandler = async (req, res) => {
|
||||||
|
const id = new mongoose.Types.ObjectId(req.params.id);
|
||||||
|
const result = await deleteObject({
|
||||||
|
model: printerProfileModel,
|
||||||
|
id,
|
||||||
|
user: req.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logger.error('No printer profile deleted:', result.error);
|
||||||
|
return res.status(result.code).send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Deleted printer profile with ID: ${result._id}`);
|
||||||
|
res.send(result);
|
||||||
|
};
|
||||||
@ -86,9 +86,16 @@ export const getPrinterRouteHandler = async (req, res) => {
|
|||||||
const result = await getObject({
|
const result = await getObject({
|
||||||
model: printerModel,
|
model: printerModel,
|
||||||
id,
|
id,
|
||||||
populate: ['vendor', 'host'],
|
populate: [
|
||||||
|
'vendor',
|
||||||
|
'host',
|
||||||
|
{ path: 'pendingSlicerUploads.file', strictPopulate: false },
|
||||||
|
{ path: 'pendingSlicerUploads.gcodeFile', strictPopulate: false },
|
||||||
|
{ path: 'pendingSlicerUploads.job', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
|
console.log('result', result.error);
|
||||||
logger.warn(`Printer not found with supplied id.`);
|
logger.warn(`Printer not found with supplied id.`);
|
||||||
return res.status(result.code).send(result);
|
return res.status(result.code).send(result);
|
||||||
}
|
}
|
||||||
@ -109,6 +116,7 @@ export const editPrinterRouteHandler = async (req, res) => {
|
|||||||
tags: req.body.tags,
|
tags: req.body.tags,
|
||||||
vendor: req.body.vendor,
|
vendor: req.body.vendor,
|
||||||
host: req.body.host,
|
host: req.body.host,
|
||||||
|
pendingSlicerUploads: req.body.pendingSlicerUploads,
|
||||||
active: req.body.active,
|
active: req.body.active,
|
||||||
};
|
};
|
||||||
// Create audit log before updating
|
// Create audit log before updating
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user