From f8c4246483cdd9ddbbac89ff9872176ded0ace73 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 5 Aug 2026 09:08:40 -0400 Subject: [PATCH] Fix model photo upload, and give network devices the model link the page assumed Three faults around vendor-model photos, found while looking at why an uploaded image did not appear. Saving a model was blocked after uploading a photo. The Image URL field was type="url", and an upload sets it to an application path such as /api/models/image/model-120.png. Native url validation demands an absolute URL with a scheme, so the browser refused to submit the form with "Please enter a URL" for a value the page had just written itself. The field is now type="text", which is what it always needed to be: it holds either a full web address or a path on this server. documentationurl stays type="url". The upload button did not appear when adding a model, only when editing one. That was deliberate - the photo is stored as model-., so it cannot be sent before the record has an id - but it reads as a missing feature, and the hint explaining it was easy to miss. A photo chosen while creating is now held and uploaded as soon as the model is saved, and it is dropped if the dialog is cancelled, so it cannot land on the next model created in the same session. Network devices could never show a photo. NetworkDeviceDetail.vue binds its hero image to networkdevice.imageurl, but networkdevices carried only vendorid, with no link to a catalog model, so nothing could populate it - a feature that looked present and could not work. Machines, PCs and printers have carried modelnumberid since July. This adds the same column and relationship, the to_dict branch that exposes modelname and imageurl, the field on the API, and a Model selector on the form so the link can actually be set. The migration is guarded the same way employees0002photo is: on a fresh database the tables come from the SQLAlchemy models, which already declare the column, so an unconditional add fails with "duplicate column name". The foreign key is created only on databases that can add one by ALTER; routing it through batch_alter_table made Alembic's column sort raise "Circular dependency detected" on the fresh-database test. Deploying this needs `flask db upgrade` and `flask plugin upgrade-all` on the server, not just a file copy. --- frontend/src/views/settings/ModelsList.vue | 87 ++++++++++++++----- plugins/network/api/routes.py | 5 +- .../frontend/views/NetworkDeviceForm.vue | 24 +++++ .../versions/0002_networkdevice_model.py | 58 +++++++++++++ plugins/network/models/network_device.py | 14 +++ tests/test_plugin_migrations.py | 3 + 6 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 plugins/network/migrations/versions/0002_networkdevice_model.py diff --git a/frontend/src/views/settings/ModelsList.vue b/frontend/src/views/settings/ModelsList.vue index 82e60e5..05f2284 100644 --- a/frontend/src/views/settings/ModelsList.vue +++ b/frontend/src/views/settings/ModelsList.vue @@ -138,34 +138,51 @@
Model image
- - Save the model first, then upload a photo. + + + + + + {{ pendingImageFile.name }} will be uploaded when you save. +
+ - Manual alternative. Uploading a photo overwrites this URL. + + Set automatically when you upload a photo. Accepts a full web address or a path on this server. +
@@ -224,6 +241,8 @@ const perPage = ref(20) const showModal = ref(false) const editingModel = ref(null) +// A photo chosen while CREATING a model, uploaded once the model has an id. +const pendingImageFile = ref(null) const saving = ref(false) const error = ref('') @@ -337,6 +356,9 @@ function openModal(m = null) { function closeModal() { showModal.value = false editingModel.value = null + // Or a photo picked for a model that was never saved would be uploaded onto + // the NEXT model created in this session. + pendingImageFile.value = null } async function saveModel() { @@ -351,7 +373,20 @@ async function saveModel() { if (editingModel.value) { await modelsApi.update(editingModel.value.modelnumberid, data) } else { - await modelsApi.create(data) + const created = await modelsApi.create(data) + // Send a photo chosen before the model existed. A failure here must not + // read as "the model was not saved" - it was. + if (pendingImageFile.value) { + const newId = created?.data?.data?.modelnumberid + if (newId) { + try { + await modelsApi.uploadImage(newId, pendingImageFile.value) + } catch (imgErr) { + console.error('Error uploading image:', imgErr) + toast.error(apiError(imgErr, 'Model saved, but the photo could not be uploaded')) + } + } + } } closeModal() loadModels() @@ -369,7 +404,14 @@ function triggerImageUpload() { async function onImageSelected(event) { const file = event.target.files?.[0] - if (!file || !editingModel.value) return + if (!file) return + // No model id yet, so hold the file and send it once saveModel has created + // the record. Nothing is written to the server until the operator saves. + if (!editingModel.value) { + pendingImageFile.value = file + if (imageFileInput.value) imageFileInput.value.value = '' + return + } uploadingImage.value = true try { const response = await modelsApi.uploadImage(editingModel.value.modelnumberid, file) @@ -387,7 +429,12 @@ async function onImageSelected(event) { } async function removeImage() { - if (!editingModel.value) return + // On a new model there is nothing on the server yet; just drop the choice. + if (!editingModel.value) { + pendingImageFile.value = null + form.value.imageurl = '' + return + } if (!confirm('Remove this model image?')) return try { await modelsApi.removeImage(editingModel.value.modelnumberid) diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 46516b3..3dfdc87 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -346,7 +346,7 @@ def create_network_device(): Optional fields: - name, serialnumber, statusid, locationid, businessunitid - - networkdevicetypeid, vendorid, hostname + - networkdevicetypeid, vendorid, modelnumberid, hostname - firmwareversion, portcount, ispoe, ismanaged, rackunit - mapx, mapy, notes """ @@ -408,6 +408,7 @@ def create_network_device(): assetid=asset.assetid, networkdevicetypeid=data.get('networkdevicetypeid'), vendorid=data.get('vendorid'), + modelnumberid=data.get('modelnumberid'), hostname=data.get('hostname'), firmwareversion=data.get('firmwareversion'), portcount=data.get('portcount'), @@ -495,7 +496,7 @@ def update_network_device(device_id: int): setattr(asset, key, data[key]) # Update network device fields - netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname', + netdev_fields = ['networkdevicetypeid', 'vendorid', 'modelnumberid', 'hostname', 'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit'] for key in netdev_fields: if key in data: diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index 3488cca..605144b 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -133,6 +133,23 @@
+
+ + + +
+ + +