Import-API hardening: network device IP endpoint + communicationtypes seeder

The legacy-import surface (docs/IMPORT-API.md) is the schema-agnostic contract
every adopting site targets; these close two gaps found while mapping the WJ
classic import.

Network device IP: POST/PUT /api/network now accept an `ipaddress` and
materialize a primary Communication (mirroring the printer route), and GET
(list + detail + create/update result) surface it. Previously a network
device's IP - which lives in the communications table, not on the extension -
had no HTTP import path at all.

communicationtypes seed: `flask seed reference-data` now seeds the eight
canonical communication types (IP/Serial/Network_Interface/USB/Parallel/VNC/
FTP/DNC), which IMPORT-API.md already documents as a prerequisite. The IP type
must exist before any asset import so printer/network routes can attach an IP.
There is no CRUD endpoint for these, so seeding is the only path.

Tests: network create/update IP upsert + GET surfacing + seed creates IP type.
204 targeted tests pass; naming + pyflakes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 11:11:01 -04:00
parent 1c6c7ba14b
commit be1ea29403
3 changed files with 128 additions and 2 deletions

View File

@@ -3,7 +3,7 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
@@ -12,6 +12,31 @@ from shopdb.api import require_permission, apply_import_timestamps
network_bp = Blueprint('network', __name__)
def _primary_ip(assetid):
"""The asset's primary IP address string, or None. Mirrors the printer
route: a device's IP lives in a Communication row, not on the extension."""
comm = (Communication.query.filter_by(assetid=assetid, isprimary=True).first()
or Communication.query.filter_by(assetid=assetid).first())
return comm.ipaddress if comm else None
def _upsert_primary_ip(asset, ip):
"""Create/update/clear the asset's primary IP Communication from an
ipaddress string. No-op if the IP CommunicationType is not seeded."""
ip = (ip or '').strip()
if not ip:
return
comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first()
if comm:
comm.ipaddress = ip
return
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
ipaddress=ip, isprimary=True))
# =============================================================================
# Network Device Types
# =============================================================================
@@ -233,6 +258,7 @@ def list_network_devices():
for netdev in items:
item = netdev.asset.to_dict() if netdev.asset else {}
item['networkdevice'] = netdev.to_dict()
item['ipaddress'] = _primary_ip(netdev.assetid)
data.append(item)
return paginated_response(data, page, per_page, total)
@@ -253,6 +279,7 @@ def get_network_device(device_id: int):
result = netdev.asset.to_dict() if netdev.asset else {}
result['networkdevice'] = netdev.to_dict()
result['ipaddress'] = _primary_ip(netdev.assetid)
return success_response(result)
@@ -272,6 +299,7 @@ def get_network_device_by_asset(asset_id: int):
result = netdev.asset.to_dict() if netdev.asset else {}
result['networkdevice'] = netdev.to_dict()
result['ipaddress'] = _primary_ip(netdev.assetid)
return success_response(result)
@@ -291,6 +319,7 @@ def get_network_device_by_hostname(hostname: str):
result = netdev.asset.to_dict() if netdev.asset else {}
result['networkdevice'] = netdev.to_dict()
result['ipaddress'] = _primary_ip(netdev.assetid)
return success_response(result)
@@ -380,6 +409,11 @@ def create_network_device():
db.session.add(netdev)
db.session.flush()
# Attach the primary IP as a Communication when supplied (a device's IP is
# not a NetworkDevice column). Mirrors the printer create route.
if data.get('ipaddress'):
_upsert_primary_ip(asset, data['ipaddress'])
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
@@ -391,6 +425,7 @@ def create_network_device():
result = asset.to_dict()
result['networkdevice'] = netdev.to_dict()
result['ipaddress'] = _primary_ip(asset.assetid)
return success_response(result, message='Network device created', http_code=201)
@@ -465,11 +500,16 @@ def update_network_device(device_id: int):
AuditLog.log('updated', 'NetworkDevice', entityid=netdev.networkdeviceid,
entityname=netdev.hostname or asset.assetnumber, changes=changes)
# Upsert the primary IP Communication when an ipaddress is supplied.
if 'ipaddress' in data:
_upsert_primary_ip(asset, data['ipaddress'])
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()
result['networkdevice'] = netdev.to_dict()
result['ipaddress'] = _primary_ip(asset.assetid)
return success_response(result, message='Network device updated')