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

@@ -0,0 +1,68 @@
"""Network device create/update attaches a primary IP Communication.
Classic network devices carry their IP in the communications table, and the
legacy import needs an HTTP path to set it. The network create/update routes
accept an `ipaddress` and materialize a primary Communication (mirroring the
printer route); GET surfaces it back.
"""
import pytest
@pytest.fixture
def network_setup(db):
from shopdb.core.models import AssetType, CommunicationType
db.session.add(AssetType(assettype='network_device', pluginname='network',
tablename='networkdevices', description='Network devices'))
db.session.add(CommunicationType(comtype='IP', description='IP'))
db.session.commit()
def test_create_network_device_with_ipaddress(client, db, auth_headers, network_setup):
"""POST /api/network with an ipaddress creates a primary Communication and
echoes the IP back."""
from shopdb.core.models import Communication
resp = client.post('/api/network', json={
'assetnumber': 'SW-CORE-01', 'name': 'Core switch',
'hostname': 'sw-core-01', 'ipaddress': '10.129.22.101',
}, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
data = resp.get_json()['data']
assert data['ipaddress'] == '10.129.22.101'
assetid = data['assetid']
comm = Communication.query.filter_by(assetid=assetid, isprimary=True).first()
assert comm is not None and comm.ipaddress == '10.129.22.101'
# GET surfaces it too.
got = client.get(f"/api/network/{data['networkdevice']['networkdeviceid']}",
headers=auth_headers)
assert got.get_json()['data']['ipaddress'] == '10.129.22.101'
def test_update_network_device_ipaddress_upserts(client, db, auth_headers, network_setup):
"""PUT changes the IP in place without creating a second Communication."""
from shopdb.core.models import Communication
created = client.post('/api/network', json={
'assetnumber': 'AP-01', 'name': 'AP', 'ipaddress': '10.0.0.5',
}, headers=auth_headers).get_json()['data']
devid = created['networkdevice']['networkdeviceid']
updated = client.put(f'/api/network/{devid}', json={'ipaddress': '10.0.0.9'},
headers=auth_headers)
assert updated.status_code == 200, updated.get_json()
assert updated.get_json()['data']['ipaddress'] == '10.0.0.9'
comms = Communication.query.filter_by(assetid=created['assetid']).all()
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
def test_seed_reference_data_creates_ip_commtype(app, db):
"""The reference-data seed now creates the IP communication type the asset
import depends on."""
from shopdb.core.models import CommunicationType
runner = app.test_cli_runner()
assert runner.invoke(args=['seed', 'reference-data']).exit_code in (0, None)
with app.app_context():
assert CommunicationType.query.filter_by(comtype='IP').first() is not None
assert CommunicationType.query.count() >= 8