Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..services.assetnumbers import generate_for_type
|
||||
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
@@ -111,7 +112,8 @@ def create_network_device_type():
|
||||
t = NetworkDeviceType(
|
||||
networkdevicetype=data['networkdevicetype'],
|
||||
description=data.get('description'),
|
||||
icon=data.get('icon'), color=data.get('color')
|
||||
icon=data.get('icon'), color=data.get('color'),
|
||||
prefix=data.get('prefix')
|
||||
)
|
||||
|
||||
db.session.add(t)
|
||||
@@ -146,7 +148,8 @@ def update_network_device_type(type_id: int):
|
||||
http_code=409
|
||||
)
|
||||
|
||||
for key in ['networkdevicetype', 'description', 'icon', 'color', 'isactive']:
|
||||
for key in ['networkdevicetype', 'description', 'icon', 'color', 'prefix',
|
||||
'isactive']:
|
||||
if key in data:
|
||||
setattr(t, key, data[key])
|
||||
|
||||
@@ -359,8 +362,17 @@ def create_network_device():
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Generate from the type prefix + name when left blank. Only when blank: a
|
||||
# device carrying a real identifier of its own - vendor tag, controller
|
||||
# name, serial - keeps it, which is the platform rule.
|
||||
if not data.get('assetnumber'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
||||
data['assetnumber'] = generate_for_type(
|
||||
data.get('networkdevicetypeid'), data.get('name'))
|
||||
if not data.get('assetnumber'):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'assetnumber is required (or give a name, and a device type with a '
|
||||
'prefix, to have one generated)')
|
||||
|
||||
# Check for duplicate assetnumber
|
||||
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
||||
|
||||
@@ -12,15 +12,24 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<!-- Not required on create: left blank it is generated from the
|
||||
device type's prefix and the name (AP-<name>). An explicit
|
||||
value always wins, because a device carrying a real
|
||||
identifier - vendor tag, controller name, serial - should
|
||||
keep it. Still locked on edit: other records join on this. -->
|
||||
<label for="assetnumber">Asset Number<span v-if="isEdit"> *</span></label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
:required="isEdit"
|
||||
:disabled="isEdit"
|
||||
:placeholder="generatedAssetNumber || 'leave blank to generate from the name'"
|
||||
/>
|
||||
<small class="form-hint" v-if="!isEdit && !form.assetnumber && generatedAssetNumber">
|
||||
Will be created as <code>{{ generatedAssetNumber }}</code>
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
@@ -343,6 +352,27 @@ const form = ref({
|
||||
})
|
||||
|
||||
const deviceTypes = ref([])
|
||||
|
||||
// Preview of what the backend will generate. Mirrors
|
||||
// plugins/network/services/assetnumbers.py - the server is authoritative and
|
||||
// re-derives it; this only shows the operator what they are about to get.
|
||||
const generatedAssetNumber = computed(() => {
|
||||
const cleaned = (value) => (value || '')
|
||||
.trim()
|
||||
.replace(/[\s_/\\]+/g, '-')
|
||||
.replace(/[^A-Za-z0-9.-]/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
const name = cleaned(form.value.name)
|
||||
if (!name) return ''
|
||||
const found = deviceTypes.value.find(
|
||||
t => String(t.networkdevicetypeid) === String(form.value.networkdevicetypeid))
|
||||
const prefix = cleaned(found?.prefix).toUpperCase()
|
||||
if (!prefix) return name
|
||||
// never stack a prefix the name already carries (IDF-03 under type IDF)
|
||||
if (name.toUpperCase().startsWith(prefix + '-')) return name
|
||||
return `${prefix}-${name}`
|
||||
})
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Network Device Type</th>
|
||||
<th>Prefix</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
@@ -22,6 +23,7 @@
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.networkdevicetypeid">
|
||||
<td>{{ t.networkdevicetype }}</td>
|
||||
<td><code v-if="t.prefix">{{ t.prefix }}-</code><span v-else class="muted">-</span></td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
@@ -31,7 +33,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">No network device types found</td>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">No network device types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -48,6 +50,21 @@
|
||||
<label>Network Device Type *</label>
|
||||
<input v-model="form.networkdevicetype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<!-- Every device of this type gets <PREFIX>-<name> as its asset
|
||||
number when the number is left blank. Blank here = the bare
|
||||
name, so a type that does not want a prefix needs no
|
||||
invented one. NOT derived from the type name: "Access Point"
|
||||
and "Access Panel" both give AP, and asset numbers are
|
||||
unique, so the second type would collide with the first on
|
||||
every device. -->
|
||||
<label>Asset Number Prefix <span class="hint">(AP, SW, SVR; blank = use the name alone)</span></label>
|
||||
<input v-model="form.prefix" type="text" class="form-control" maxlength="12"
|
||||
placeholder="e.g. APNL" />
|
||||
<small class="form-hint" v-if="form.prefix">
|
||||
New devices will be numbered <code>{{ form.prefix.toUpperCase() }}-<name></code>
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
@@ -88,7 +105,7 @@ const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ networkdevicetype: '', description: '', color: '', isactive: true })
|
||||
const form = ref({ networkdevicetype: '', prefix: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
@@ -107,8 +124,8 @@ async function loadData() {
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { networkdevicetype: item.networkdevicetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { networkdevicetype: '', description: '', color: '', isactive: true }
|
||||
? { networkdevicetype: item.networkdevicetype || '', prefix: item.prefix || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { networkdevicetype: '', prefix: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Asset-number prefix per network device type.
|
||||
|
||||
Every network device already follows one convention, applied by hand:
|
||||
AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. 45 records, no exceptions. This
|
||||
stores the prefix on the type so the create path can generate the asset number
|
||||
instead of asking someone to retype the same value twice.
|
||||
|
||||
Nullable: a type with no prefix generates the bare name. Nobody has to invent
|
||||
one for a type that does not want it.
|
||||
|
||||
It deliberately is NOT derived from the type name - 'Access Point' and 'Access
|
||||
Panel' both initialise to AP, and assetnumber is unique, so the second type
|
||||
would collide with the first on every device it created.
|
||||
|
||||
Backfilled for the four types already in use so existing conventions carry
|
||||
forward. A site whose types are named differently gets NULL and generates bare
|
||||
names, which is correct rather than wrong.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'network0003prefix'
|
||||
down_revision = 'network0002model'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# type name -> prefix, from what the existing records already use
|
||||
SEEDPREFIXES = {
|
||||
'Access Point': 'AP',
|
||||
'Switch': 'SW',
|
||||
'Server': 'SVR',
|
||||
'IDF': 'IDF',
|
||||
}
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Guarded like network0002model: on a FRESH database the table is built from
|
||||
# the SQLAlchemy models, which already declare this column, so an
|
||||
# unconditional add fails with "duplicate column name".
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'networkdevicetypes' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('networkdevicetypes')}
|
||||
if 'prefix' not in cols:
|
||||
op.add_column('networkdevicetypes',
|
||||
sa.Column('prefix', sa.String(length=12), nullable=True))
|
||||
|
||||
# Backfill only where the type exists and has no prefix yet, so a re-run and
|
||||
# a site that already set its own are both left alone.
|
||||
for typename, prefix in SEEDPREFIXES.items():
|
||||
bind.execute(
|
||||
sa.text('UPDATE networkdevicetypes SET prefix = :prefix '
|
||||
'WHERE networkdevicetype = :typename '
|
||||
'AND (prefix IS NULL OR prefix = :empty)'),
|
||||
{'prefix': prefix, 'typename': typename, 'empty': ''})
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'networkdevicetypes' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('networkdevicetypes')}
|
||||
if 'prefix' in cols:
|
||||
op.drop_column('networkdevicetypes', 'prefix')
|
||||
@@ -16,6 +16,16 @@ class NetworkDeviceType(BaseModel):
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
||||
# Asset-number prefix for devices of this type: AP-<name>, SW-<name>.
|
||||
# NULLABLE on purpose - a type with no prefix generates the bare name, so
|
||||
# nobody has to invent one for a type that does not want it.
|
||||
#
|
||||
# It cannot be derived from the type NAME: 'Access Point' and 'Access Panel'
|
||||
# both initialise to AP, and assetnumber is unique, so the second type would
|
||||
# collide with the first on every device.
|
||||
prefix = db.Column(
|
||||
db.String(12),
|
||||
comment='Asset-number prefix for this type (AP, SW, SVR). Blank = none')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDeviceType {self.networkdevicetype}>"
|
||||
|
||||
1
plugins/network/services/__init__.py
Normal file
1
plugins/network/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Network plugin services."""
|
||||
70
plugins/network/services/assetnumbers.py
Normal file
70
plugins/network/services/assetnumbers.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Generate a network device's asset number from its type prefix and name.
|
||||
|
||||
Every network device on this fleet already follows one convention, applied by
|
||||
hand: AP-<name>, SW-<name>, SVR-<name>, IDF-<name>. This turns that into
|
||||
something the create path does, so the same value is not typed twice.
|
||||
|
||||
Generation only fills a BLANK asset number. A device that carries a real
|
||||
identifier of its own - a vendor tag, a controller name, a serial - keeps it.
|
||||
That is the platform rule: adopt an external identifier where one exists, derive
|
||||
one only where none does.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# Characters that must not reach a unique business key. assetnumber ends up in
|
||||
# URLs and in joins other subsystems make, and the existing data already shows
|
||||
# why this matters: IDF-Telco-Demarc-#1 came from the name 'Telco Demarc #1',
|
||||
# carrying a '#' into an identifier.
|
||||
_SEPARATORS = re.compile(r'[\s_/\\]+')
|
||||
_ILLEGAL = re.compile(r'[^A-Za-z0-9.-]')
|
||||
_RUNS = re.compile(r'-{2,}')
|
||||
|
||||
|
||||
def sanitize(value):
|
||||
"""A name reduced to something safe to use as an identifier.
|
||||
|
||||
Whitespace and slashes become single dashes; anything outside
|
||||
letters/digits/dot/dash is dropped rather than transliterated, because a
|
||||
guessed transliteration in a business key is worse than a shorter one.
|
||||
"""
|
||||
text = (value or '').strip()
|
||||
if not text:
|
||||
return ''
|
||||
text = _SEPARATORS.sub('-', text)
|
||||
text = _ILLEGAL.sub('', text)
|
||||
text = _RUNS.sub('-', text)
|
||||
return text.strip('-')
|
||||
|
||||
|
||||
def generate(prefix, name):
|
||||
"""'<PREFIX>-<name>', or the bare name when the type has no prefix.
|
||||
|
||||
Returns '' when there is nothing to build from, so the caller can fall back
|
||||
to demanding an explicit value rather than inventing one.
|
||||
|
||||
Does NOT stack an existing prefix: a name already starting with the prefix
|
||||
(IDF-03 under type IDF) is returned as-is, because IDF-IDF-03 is nobody's
|
||||
intent. Matched case-insensitively on the prefix plus its dash.
|
||||
"""
|
||||
cleanname = sanitize(name)
|
||||
if not cleanname:
|
||||
return ''
|
||||
cleanprefix = sanitize(prefix).upper()
|
||||
if not cleanprefix:
|
||||
return cleanname
|
||||
if cleanname.upper().startswith(cleanprefix + '-'):
|
||||
return cleanname
|
||||
return '{}-{}'.format(cleanprefix, cleanname)
|
||||
|
||||
|
||||
def generate_for_type(networkdevicetypeid, name):
|
||||
"""Same, looking the prefix up from the device type. '' if no type given."""
|
||||
if not networkdevicetypeid:
|
||||
return sanitize(name)
|
||||
from shopdb.api import db
|
||||
from ..models import NetworkDeviceType
|
||||
|
||||
devicetype = db.session.get(NetworkDeviceType, networkdevicetypeid)
|
||||
return generate(devicetype.prefix if devicetype else None, name)
|
||||
Reference in New Issue
Block a user