Model photo upload fixes; network devices linked to catalog models
This commit is contained in:
@@ -138,7 +138,14 @@
|
|||||||
<div class="image-manage">
|
<div class="image-manage">
|
||||||
<img v-if="form.imageurl" :src="withBase(form.imageurl)" alt="Model image" class="image-thumb" />
|
<img v-if="form.imageurl" :src="withBase(form.imageurl)" alt="Model image" class="image-thumb" />
|
||||||
<div class="image-actions">
|
<div class="image-actions">
|
||||||
<template v-if="editingModel">
|
<!--
|
||||||
|
Offered when CREATING too. The photo is stored as
|
||||||
|
model-<id>.<ext>, so it cannot be sent before the model has
|
||||||
|
an id - but making the operator save, reopen and come back
|
||||||
|
just to add a picture reads as a missing feature. On a new
|
||||||
|
model the file is held here and uploaded immediately after
|
||||||
|
the model is created.
|
||||||
|
-->
|
||||||
<input
|
<input
|
||||||
ref="imageFileInput"
|
ref="imageFileInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -147,25 +154,35 @@
|
|||||||
@change="onImageSelected"
|
@change="onImageSelected"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingImage" @click="triggerImageUpload">
|
<button type="button" class="btn btn-secondary btn-sm" :disabled="uploadingImage" @click="triggerImageUpload">
|
||||||
{{ uploadingImage ? 'Uploading...' : (form.imageurl ? 'Replace' : 'Upload') }}
|
{{ uploadingImage ? 'Uploading...' : ((form.imageurl || pendingImageFile) ? 'Replace' : 'Upload') }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="form.imageurl" type="button" class="btn btn-danger btn-sm" @click="removeImage">Remove</button>
|
<button v-if="form.imageurl || pendingImageFile" type="button" class="btn btn-danger btn-sm" @click="removeImage">Remove</button>
|
||||||
</template>
|
<small v-if="pendingImageFile" class="text-muted pending-note">
|
||||||
<small v-else class="text-muted">Save the model first, then upload a photo.</small>
|
{{ pendingImageFile.name }} will be uploaded when you save.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="imageurl">Image URL</label>
|
<label for="imageurl">Image URL</label>
|
||||||
|
<!--
|
||||||
|
type="text", NOT type="url". An uploaded photo sets this field to
|
||||||
|
an application path such as /api/models/image/model-120.png, and
|
||||||
|
native url validation demands an absolute URL with a scheme - so
|
||||||
|
the browser refused to save the form with "Please enter a URL"
|
||||||
|
for a value this page had just written itself.
|
||||||
|
-->
|
||||||
<input
|
<input
|
||||||
id="imageurl"
|
id="imageurl"
|
||||||
v-model="form.imageurl"
|
v-model="form.imageurl"
|
||||||
type="url"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="https://..."
|
placeholder="https://... or /api/models/image/..."
|
||||||
/>
|
/>
|
||||||
<small class="text-muted">Manual alternative. Uploading a photo overwrites this URL.</small>
|
<small class="text-muted">
|
||||||
|
Set automatically when you upload a photo. Accepts a full web address or a path on this server.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -224,6 +241,8 @@ const perPage = ref(20)
|
|||||||
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const editingModel = ref(null)
|
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 saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -337,6 +356,9 @@ function openModal(m = null) {
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
editingModel.value = null
|
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() {
|
async function saveModel() {
|
||||||
@@ -351,7 +373,20 @@ async function saveModel() {
|
|||||||
if (editingModel.value) {
|
if (editingModel.value) {
|
||||||
await modelsApi.update(editingModel.value.modelnumberid, data)
|
await modelsApi.update(editingModel.value.modelnumberid, data)
|
||||||
} else {
|
} 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()
|
closeModal()
|
||||||
loadModels()
|
loadModels()
|
||||||
@@ -369,7 +404,14 @@ function triggerImageUpload() {
|
|||||||
|
|
||||||
async function onImageSelected(event) {
|
async function onImageSelected(event) {
|
||||||
const file = event.target.files?.[0]
|
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
|
uploadingImage.value = true
|
||||||
try {
|
try {
|
||||||
const response = await modelsApi.uploadImage(editingModel.value.modelnumberid, file)
|
const response = await modelsApi.uploadImage(editingModel.value.modelnumberid, file)
|
||||||
@@ -387,7 +429,12 @@ async function onImageSelected(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeImage() {
|
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
|
if (!confirm('Remove this model image?')) return
|
||||||
try {
|
try {
|
||||||
await modelsApi.removeImage(editingModel.value.modelnumberid)
|
await modelsApi.removeImage(editingModel.value.modelnumberid)
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ def create_network_device():
|
|||||||
|
|
||||||
Optional fields:
|
Optional fields:
|
||||||
- name, serialnumber, statusid, locationid, businessunitid
|
- name, serialnumber, statusid, locationid, businessunitid
|
||||||
- networkdevicetypeid, vendorid, hostname
|
- networkdevicetypeid, vendorid, modelnumberid, hostname
|
||||||
- firmwareversion, portcount, ispoe, ismanaged, rackunit
|
- firmwareversion, portcount, ispoe, ismanaged, rackunit
|
||||||
- mapx, mapy, notes
|
- mapx, mapy, notes
|
||||||
"""
|
"""
|
||||||
@@ -408,6 +408,7 @@ def create_network_device():
|
|||||||
assetid=asset.assetid,
|
assetid=asset.assetid,
|
||||||
networkdevicetypeid=data.get('networkdevicetypeid'),
|
networkdevicetypeid=data.get('networkdevicetypeid'),
|
||||||
vendorid=data.get('vendorid'),
|
vendorid=data.get('vendorid'),
|
||||||
|
modelnumberid=data.get('modelnumberid'),
|
||||||
hostname=data.get('hostname'),
|
hostname=data.get('hostname'),
|
||||||
firmwareversion=data.get('firmwareversion'),
|
firmwareversion=data.get('firmwareversion'),
|
||||||
portcount=data.get('portcount'),
|
portcount=data.get('portcount'),
|
||||||
@@ -495,7 +496,7 @@ def update_network_device(device_id: int):
|
|||||||
setattr(asset, key, data[key])
|
setattr(asset, key, data[key])
|
||||||
|
|
||||||
# Update network device fields
|
# Update network device fields
|
||||||
netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname',
|
netdev_fields = ['networkdevicetypeid', 'vendorid', 'modelnumberid', 'hostname',
|
||||||
'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit']
|
'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit']
|
||||||
for key in netdev_fields:
|
for key in netdev_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
|
|||||||
@@ -133,6 +133,23 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<!--
|
||||||
|
The catalog model is where the device photo comes from: the
|
||||||
|
detail page's hero image reads the model's imageurl, exactly as
|
||||||
|
machines, PCs and printers do.
|
||||||
|
-->
|
||||||
|
<label for="modelnumberid">Model</label>
|
||||||
|
<select id="modelnumberid" v-model="form.modelnumberid" class="form-control">
|
||||||
|
<option value="">Select Model</option>
|
||||||
|
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
|
||||||
|
{{ m.modelnumber }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="firmwareversion">Firmware Version</label>
|
<label for="firmwareversion">Firmware Version</label>
|
||||||
<input
|
<input
|
||||||
@@ -251,6 +268,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import {
|
import {
|
||||||
networkApi,
|
networkApi,
|
||||||
vendorsApi,
|
vendorsApi,
|
||||||
|
modelsApi,
|
||||||
locationsApi,
|
locationsApi,
|
||||||
assetsApi,
|
assetsApi,
|
||||||
businessunitsApi
|
businessunitsApi
|
||||||
@@ -284,6 +302,7 @@ const form = ref({
|
|||||||
hostname: '',
|
hostname: '',
|
||||||
networkdevicetypeid: '',
|
networkdevicetypeid: '',
|
||||||
vendorid: '',
|
vendorid: '',
|
||||||
|
modelnumberid: '',
|
||||||
firmwareversion: '',
|
firmwareversion: '',
|
||||||
portcount: null,
|
portcount: null,
|
||||||
rackunit: '',
|
rackunit: '',
|
||||||
@@ -296,6 +315,7 @@ const form = ref({
|
|||||||
|
|
||||||
const deviceTypes = ref([])
|
const deviceTypes = ref([])
|
||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
|
const models = ref([])
|
||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
const statuses = ref([])
|
const statuses = ref([])
|
||||||
const businessUnits = ref([])
|
const businessUnits = ref([])
|
||||||
@@ -329,6 +349,8 @@ async function loadVendors() {
|
|||||||
try {
|
try {
|
||||||
const response = await vendorsApi.list({ perpage: 100 })
|
const response = await vendorsApi.list({ perpage: 100 })
|
||||||
vendors.value = response.data.data || []
|
vendors.value = response.data.data || []
|
||||||
|
const modelResponse = await modelsApi.list({ perpage: 500 })
|
||||||
|
models.value = modelResponse.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading vendors:', err)
|
console.error('Error loading vendors:', err)
|
||||||
}
|
}
|
||||||
@@ -385,6 +407,7 @@ async function loadDevice() {
|
|||||||
form.value.hostname = data.networkdevice.hostname || ''
|
form.value.hostname = data.networkdevice.hostname || ''
|
||||||
form.value.networkdevicetypeid = data.networkdevice.networkdevicetypeid || ''
|
form.value.networkdevicetypeid = data.networkdevice.networkdevicetypeid || ''
|
||||||
form.value.vendorid = data.networkdevice.vendorid || ''
|
form.value.vendorid = data.networkdevice.vendorid || ''
|
||||||
|
form.value.modelnumberid = data.networkdevice.modelnumberid || ''
|
||||||
form.value.firmwareversion = data.networkdevice.firmwareversion || ''
|
form.value.firmwareversion = data.networkdevice.firmwareversion || ''
|
||||||
form.value.portcount = data.networkdevice.portcount
|
form.value.portcount = data.networkdevice.portcount
|
||||||
form.value.rackunit = data.networkdevice.rackunit || ''
|
form.value.rackunit = data.networkdevice.rackunit || ''
|
||||||
@@ -414,6 +437,7 @@ async function submitForm() {
|
|||||||
hostname: form.value.hostname || null,
|
hostname: form.value.hostname || null,
|
||||||
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
||||||
vendorid: form.value.vendorid || null,
|
vendorid: form.value.vendorid || null,
|
||||||
|
modelnumberid: form.value.modelnumberid || null,
|
||||||
firmwareversion: form.value.firmwareversion || null,
|
firmwareversion: form.value.firmwareversion || null,
|
||||||
portcount: form.value.portcount || null,
|
portcount: form.value.portcount || null,
|
||||||
rackunit: form.value.rackunit || null,
|
rackunit: form.value.rackunit || null,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Link a network device to a catalog model.
|
||||||
|
|
||||||
|
Machines, PCs and printers all carry modelnumberid, which is what lets an asset
|
||||||
|
detail page show the photo uploaded against its vendor model. Network devices
|
||||||
|
carried only vendorid, so NetworkDeviceDetail.vue rendered a hero image bound to
|
||||||
|
`networkdevice.imageurl` that nothing could ever populate - a feature that
|
||||||
|
looked present and could not work.
|
||||||
|
|
||||||
|
Nullable, with no backfill: a device whose model is not recorded is normal, and
|
||||||
|
guessing one from the vendor would put wrong photos on real hardware.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'network0002model'
|
||||||
|
down_revision = 'network0001anchor'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Guarded exactly like employees0002photo. On a FRESH database the tables
|
||||||
|
# are built from the SQLAlchemy models, which already declare this column,
|
||||||
|
# so an unconditional add fails with "duplicate column name". On an existing
|
||||||
|
# server the column really is missing and gets added.
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if 'networkdevices' not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
cols = {c['name'] for c in insp.get_columns('networkdevices')}
|
||||||
|
if 'modelnumberid' not in cols:
|
||||||
|
op.add_column('networkdevices', sa.Column('modelnumberid', sa.Integer(), nullable=True))
|
||||||
|
# Only where it is possible. SQLite has no ALTER TABLE ADD CONSTRAINT,
|
||||||
|
# and routing this through batch_alter_table - which rebuilds the table -
|
||||||
|
# made Alembic's column sort raise "Circular dependency detected". The
|
||||||
|
# column is what the relationship needs; the constraint is integrity on
|
||||||
|
# the real database.
|
||||||
|
if bind.dialect.name != 'sqlite':
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_networkdevices_modelnumberid',
|
||||||
|
'networkdevices', 'models',
|
||||||
|
['modelnumberid'], ['modelnumberid'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if 'networkdevices' not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
cols = {c['name'] for c in insp.get_columns('networkdevices')}
|
||||||
|
if 'modelnumberid' in cols:
|
||||||
|
if bind.dialect.name != 'sqlite':
|
||||||
|
op.drop_constraint('fk_networkdevices_modelnumberid', 'networkdevices',
|
||||||
|
type_='foreignkey')
|
||||||
|
op.drop_column('networkdevices', 'modelnumberid')
|
||||||
@@ -49,6 +49,13 @@ class NetworkDevice(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Vendor
|
# Vendor
|
||||||
|
modelnumberid = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('models.modelnumberid'),
|
||||||
|
nullable=True,
|
||||||
|
comment='Catalog model, which is where the device photo comes from'
|
||||||
|
)
|
||||||
|
|
||||||
vendorid = db.Column(
|
vendorid = db.Column(
|
||||||
db.Integer,
|
db.Integer,
|
||||||
db.ForeignKey('vendors.vendorid'),
|
db.ForeignKey('vendors.vendorid'),
|
||||||
@@ -97,6 +104,7 @@ class NetworkDevice(BaseModel):
|
|||||||
)
|
)
|
||||||
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
|
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
|
||||||
vendor = db.relationship('Vendor', backref='network_devices')
|
vendor = db.relationship('Vendor', backref='network_devices')
|
||||||
|
model = db.relationship('Model', backref='network_devices')
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.Index('idx_netdev_type', 'networkdevicetypeid'),
|
db.Index('idx_netdev_type', 'networkdevicetypeid'),
|
||||||
@@ -116,5 +124,11 @@ class NetworkDevice(BaseModel):
|
|||||||
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
|
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
|
||||||
if self.vendor:
|
if self.vendor:
|
||||||
result['vendorname'] = self.vendor.vendor
|
result['vendorname'] = self.vendor.vendor
|
||||||
|
# Same shape as machines, PCs and printers: the detail page's hero image
|
||||||
|
# binds to imageurl, and it comes from the catalog model, not the device.
|
||||||
|
if self.model:
|
||||||
|
result['modelname'] = self.model.modelnumber
|
||||||
|
if self.model.imageurl:
|
||||||
|
result['imageurl'] = self.model.imageurl
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
|||||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||||
|
# network links a device to a catalog model, which is where its photo comes
|
||||||
|
# from - machines, PCs and printers already had that link.
|
||||||
|
EXPECTED_HEAD_REVISION['network'] = 'network0002model'
|
||||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||||
# the per-transaction revision column.
|
# the per-transaction revision column.
|
||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||||
|
|||||||
Reference in New Issue
Block a user