Fix model photo upload, and give network devices the model link the page assumed
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 6s

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-<id>.<ext>, 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.
This commit is contained in:
cproudlock
2026-08-05 09:08:40 -04:00
parent 92a90fcec6
commit f8c4246483
6 changed files with 169 additions and 22 deletions

View File

@@ -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:

View File

@@ -133,6 +133,23 @@
</option>
</select>
</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">
<label for="firmwareversion">Firmware Version</label>
<input
@@ -251,6 +268,7 @@ import { useRoute, useRouter } from 'vue-router'
import {
networkApi,
vendorsApi,
modelsApi,
locationsApi,
assetsApi,
businessunitsApi
@@ -284,6 +302,7 @@ const form = ref({
hostname: '',
networkdevicetypeid: '',
vendorid: '',
modelnumberid: '',
firmwareversion: '',
portcount: null,
rackunit: '',
@@ -296,6 +315,7 @@ const form = ref({
const deviceTypes = ref([])
const vendors = ref([])
const models = ref([])
const locations = ref([])
const statuses = ref([])
const businessUnits = ref([])
@@ -329,6 +349,8 @@ async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
const modelResponse = await modelsApi.list({ perpage: 500 })
models.value = modelResponse.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
@@ -385,6 +407,7 @@ async function loadDevice() {
form.value.hostname = data.networkdevice.hostname || ''
form.value.networkdevicetypeid = data.networkdevice.networkdevicetypeid || ''
form.value.vendorid = data.networkdevice.vendorid || ''
form.value.modelnumberid = data.networkdevice.modelnumberid || ''
form.value.firmwareversion = data.networkdevice.firmwareversion || ''
form.value.portcount = data.networkdevice.portcount
form.value.rackunit = data.networkdevice.rackunit || ''
@@ -414,6 +437,7 @@ async function submitForm() {
hostname: form.value.hostname || null,
networkdevicetypeid: form.value.networkdevicetypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
firmwareversion: form.value.firmwareversion || null,
portcount: form.value.portcount || null,
rackunit: form.value.rackunit || null,

View File

@@ -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')

View File

@@ -49,6 +49,13 @@ class NetworkDevice(BaseModel):
)
# 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(
db.Integer,
db.ForeignKey('vendors.vendorid'),
@@ -97,6 +104,7 @@ class NetworkDevice(BaseModel):
)
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
vendor = db.relationship('Vendor', backref='network_devices')
model = db.relationship('Model', backref='network_devices')
__table_args__ = (
db.Index('idx_netdev_type', 'networkdevicetypeid'),
@@ -116,5 +124,11 @@ class NetworkDevice(BaseModel):
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.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