Enforce plugin contract purity: single import surface via shopdb.api

Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 16:45:06 -04:00
parent 37ffb4add5
commit f663cc5bbe
29 changed files with 2152 additions and 2107 deletions

View File

@@ -1,121 +1,120 @@
"""Network device plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class NetworkDeviceType(BaseModel):
"""
Network device type classification.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
"""
__tablename__ = 'networkdevicetypes'
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<NetworkDeviceType {self.networkdevicetype}>"
class NetworkDevice(BaseModel):
"""
Network device-specific extension data.
Links to core Asset table via assetid.
Stores network device-specific fields like hostname, firmware, ports, etc.
"""
__tablename__ = 'networkdevices'
networkdeviceid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Network device classification
networkdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
nullable=True
)
# Vendor
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
index=True,
comment='Network hostname'
)
# Firmware/software version
firmwareversion = db.Column(db.String(100), nullable=True)
# Physical characteristics
portcount = db.Column(
db.Integer,
nullable=True,
comment='Number of ports (for switches)'
)
# Features
ispoe = db.Column(
db.Boolean,
default=False,
comment='Power over Ethernet capable'
)
ismanaged = db.Column(
db.Boolean,
default=False,
comment='Managed device (SNMP, web interface, etc.)'
)
# For IDF/closet locations
rackunit = db.Column(
db.String(20),
nullable=True,
comment='Rack unit position (e.g., U1, U5)'
)
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('network_device', uselist=False, lazy='joined')
)
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
vendor = db.relationship('Vendor', backref='network_devices')
__table_args__ = (
db.Index('idx_netdev_type', 'networkdevicetypeid'),
db.Index('idx_netdev_hostname', 'hostname'),
db.Index('idx_netdev_vendor', 'vendorid'),
)
def __repr__(self):
return f"<NetworkDevice {self.hostname or self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.networkdevicetype:
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendorname'] = self.vendor.vendor
return result
"""Network device plugin models."""
from shopdb.api import db, BaseModel
class NetworkDeviceType(BaseModel):
"""
Network device type classification.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
"""
__tablename__ = 'networkdevicetypes'
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<NetworkDeviceType {self.networkdevicetype}>"
class NetworkDevice(BaseModel):
"""
Network device-specific extension data.
Links to core Asset table via assetid.
Stores network device-specific fields like hostname, firmware, ports, etc.
"""
__tablename__ = 'networkdevices'
networkdeviceid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Network device classification
networkdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
nullable=True
)
# Vendor
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
index=True,
comment='Network hostname'
)
# Firmware/software version
firmwareversion = db.Column(db.String(100), nullable=True)
# Physical characteristics
portcount = db.Column(
db.Integer,
nullable=True,
comment='Number of ports (for switches)'
)
# Features
ispoe = db.Column(
db.Boolean,
default=False,
comment='Power over Ethernet capable'
)
ismanaged = db.Column(
db.Boolean,
default=False,
comment='Managed device (SNMP, web interface, etc.)'
)
# For IDF/closet locations
rackunit = db.Column(
db.String(20),
nullable=True,
comment='Rack unit position (e.g., U1, U5)'
)
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('network_device', uselist=False, lazy='joined')
)
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
vendor = db.relationship('Vendor', backref='network_devices')
__table_args__ = (
db.Index('idx_netdev_type', 'networkdevicetypeid'),
db.Index('idx_netdev_hostname', 'hostname'),
db.Index('idx_netdev_vendor', 'vendorid'),
)
def __repr__(self):
return f"<NetworkDevice {self.hostname or self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.networkdevicetype:
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendorname'] = self.vendor.vendor
return result

View File

@@ -1,146 +1,145 @@
"""Subnet and VLAN models for network plugin."""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
class VLAN(BaseModel):
"""
VLAN definition.
Represents a virtual LAN for network segmentation.
"""
__tablename__ = 'vlans'
vlanid = db.Column(db.Integer, primary_key=True)
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
description = db.Column(db.Text, nullable=True)
# Optional classification
vlantype = db.Column(
db.String(50),
nullable=True,
comment='Type: data, voice, management, guest, etc.'
)
# Relationships
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
__table_args__ = (
db.Index('idx_vlan_number', 'vlannumber'),
)
def __repr__(self):
return f"<VLAN {self.vlannumber} - {self.name}>"
def to_dict(self):
"""Convert to dictionary."""
result = super().to_dict()
result['subnetcount'] = self.subnets.count() if self.subnets else 0
return result
class Subnet(BaseModel):
"""
Subnet/IP network definition.
Represents an IP subnet with optional VLAN association.
"""
__tablename__ = 'subnets'
subnetid = db.Column(db.Integer, primary_key=True)
# Network definition
cidr = db.Column(
db.String(18),
unique=True,
nullable=False,
comment='CIDR notation (e.g., 10.1.1.0/24)'
)
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
description = db.Column(db.Text, nullable=True)
# Network details
gatewayip = db.Column(
db.String(15),
nullable=True,
comment='Default gateway IP address'
)
subnetmask = db.Column(
db.String(15),
nullable=True,
comment='Subnet mask (e.g., 255.255.255.0)'
)
networkaddress = db.Column(
db.String(15),
nullable=True,
comment='Network address (e.g., 10.1.1.0)'
)
broadcastaddress = db.Column(
db.String(15),
nullable=True,
comment='Broadcast address (e.g., 10.1.1.255)'
)
# VLAN association
vlanid = db.Column(
db.Integer,
db.ForeignKey('vlans.vlanid'),
nullable=True
)
# Classification
subnettype = db.Column(
db.String(50),
nullable=True,
comment='Type: production, development, management, dmz, etc.'
)
# Location association
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
# DHCP settings
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
# DNS settings
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
# Relationships
location = db.relationship('Location', backref='subnets')
__table_args__ = (
db.Index('idx_subnet_cidr', 'cidr'),
db.Index('idx_subnet_vlan', 'vlanid'),
db.Index('idx_subnet_location', 'locationid'),
)
def __repr__(self):
return f"<Subnet {self.cidr} - {self.name}>"
@property
def vlan_number(self):
"""Get the VLAN number."""
return self.vlan.vlannumber if self.vlan else None
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add VLAN info
if self.vlan:
result['vlannumber'] = self.vlan.vlannumber
result['vlanname'] = self.vlan.name
# Add location info
if self.location:
result['locationname'] = self.location.locationname
return result
"""Subnet and VLAN models for network plugin."""
from shopdb.api import db, BaseModel
class VLAN(BaseModel):
"""
VLAN definition.
Represents a virtual LAN for network segmentation.
"""
__tablename__ = 'vlans'
vlanid = db.Column(db.Integer, primary_key=True)
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
description = db.Column(db.Text, nullable=True)
# Optional classification
vlantype = db.Column(
db.String(50),
nullable=True,
comment='Type: data, voice, management, guest, etc.'
)
# Relationships
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
__table_args__ = (
db.Index('idx_vlan_number', 'vlannumber'),
)
def __repr__(self):
return f"<VLAN {self.vlannumber} - {self.name}>"
def to_dict(self):
"""Convert to dictionary."""
result = super().to_dict()
result['subnetcount'] = self.subnets.count() if self.subnets else 0
return result
class Subnet(BaseModel):
"""
Subnet/IP network definition.
Represents an IP subnet with optional VLAN association.
"""
__tablename__ = 'subnets'
subnetid = db.Column(db.Integer, primary_key=True)
# Network definition
cidr = db.Column(
db.String(18),
unique=True,
nullable=False,
comment='CIDR notation (e.g., 10.1.1.0/24)'
)
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
description = db.Column(db.Text, nullable=True)
# Network details
gatewayip = db.Column(
db.String(15),
nullable=True,
comment='Default gateway IP address'
)
subnetmask = db.Column(
db.String(15),
nullable=True,
comment='Subnet mask (e.g., 255.255.255.0)'
)
networkaddress = db.Column(
db.String(15),
nullable=True,
comment='Network address (e.g., 10.1.1.0)'
)
broadcastaddress = db.Column(
db.String(15),
nullable=True,
comment='Broadcast address (e.g., 10.1.1.255)'
)
# VLAN association
vlanid = db.Column(
db.Integer,
db.ForeignKey('vlans.vlanid'),
nullable=True
)
# Classification
subnettype = db.Column(
db.String(50),
nullable=True,
comment='Type: production, development, management, dmz, etc.'
)
# Location association
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
# DHCP settings
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
# DNS settings
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
# Relationships
location = db.relationship('Location', backref='subnets')
__table_args__ = (
db.Index('idx_subnet_cidr', 'cidr'),
db.Index('idx_subnet_vlan', 'vlanid'),
db.Index('idx_subnet_location', 'locationid'),
)
def __repr__(self):
return f"<Subnet {self.cidr} - {self.name}>"
@property
def vlan_number(self):
"""Get the VLAN number."""
return self.vlan.vlannumber if self.vlan else None
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add VLAN info
if self.vlan:
result['vlannumber'] = self.vlan.vlannumber
result['vlanname'] = self.vlan.name
# Add location info
if self.location:
result['locationname'] = self.location.locationname
return result